Introduction
在这个实验中,你将在多个同时活跃的用户模式环境中实现抢占式多任务处理。
在A部分,你将为JOS添加多处理器支持,实现轮转调度,并添加基本的环境管理系统调用(用于创建和销毁环境,以及分配/映射内存的调用)。
在B部分,你将实现类似Unix的fork()功能,允许用户模式环境创建自身的副本。
最后,在C部分,你将添加进程间通信(IPC)的支持,允许不同的用户模式环境明确地进行通信和同步。你还将添加对硬件时钟中断和抢占的支持。
Getting Started
跟lab3一样git
包含的一些新文件
kern/cpu.h ——用于多处理器支持的内核私有定义 kern/mpconfig.c ——用于读取多处理器配置的代码 kern/lapic.c ——驱动每个处理器上的本地 APIC 单元的内核代码 kern/mpentry.S ——用于非引导 CPU 的汇编语言入口代码 kern/spinlock.h ——用于自旋锁的内核私有定义,包括大内核锁 kern/spinlock.c ——实现自旋锁的内核代码 kern/sched.c ——即将实现的调度器的代码框架
Part A: Multiprocessor Support and Cooperative Multitasking
多处理器支持及合作式多任务处理
在实验的第一部分中,你将首先扩展 JOS 以在多处理器系统上运行,然后实现一些新的 JOS 内核系统调用,允许用户级环境创建额外的新环境。你还将实现协作式轮转调度,允许内核在当前环境自愿释放 CPU(或退出)时从一个环境切换到另一个环境。在实验的第C部分,你将实现抢占式调度,这使得内核能够在经过一定时间后重新控制 CPU,即使环境不配合也能够重新控制CPU。
Multiprocessor Support
我们将使JOS支持“对称多处理”(SMP),这是一种多处理器模型,其中所有CPU均具有对系统资源(如内存和I/O总线)的等效访问权限。虽然在SMP中所有CPU在功能上都是相同的,但在引导过程中它们可以被分类为两种类型:
- 引导处理器(BSP)负责初始化系统和引导操作系统;
- 应用处理器(AP)仅在操作系统启动并运行后由BSP激活。
哪个处理器是BSP由硬件和BIOS决定。到目前为止,你所有现有的JOS代码都是运行在BSP上的。
在SMP系统中,每个CPU都有一个相应的本地APIC(LAPIC)(本地高级可编程中断控制器)单元。LAPIC单元负责在整个系统中传递中断。LAPIC还为连接的CPU提供唯一的标识符。在这个实验中,我们利用LAPIC单元的以下基本功能(位于kern/lapic.c中):
- 读取LAPIC标识符(APIC ID)以确定我们的代码当前正在哪个CPU上运行(参见cpunum())。
- 从BSP向AP发送启动中断(IPI)以启动其他CPU(参见lapic_startap())。
- 在第C部分中,我们编程LAPIC的内置定时器以触发时钟中断,支持抢占式多任务处理(参见apic_init())。
CPU通过内存映射IO(MMIO)访问它对应的APIC,这样就能通过访问内存达到访问设备寄存器的目的。LAPIC从物理地址0xFE000000开始,JOS将通过MMIOBASE虚拟地址访问该物理地址。
-
内存映射
是的,内存映射通常指的是将某些资源(比如文件、硬件设备的寄存器或物理内存区域)映射到进程的虚拟地址空间中。
在操作系统中,内存映射是一种将外部资源或物理内存区域映射到进程的虚拟地址空间中的机制。这种映射使得进程可以通过虚拟地址来访问这些资源,而无需了解底层的物理硬件细节。这些资源可能包括磁盘文件、共享库、设备寄存器或其他进程的内存区域等。
通过内存映射,操作系统可以为每个进程创建一个连续的虚拟地址空间,而不管物理内存的实际布局是什么样的。这使得操作系统能够更灵活地管理进程的内存,实现了诸如动态链接库的加载、文件映射、共享内存等功能。
-
为什么要进行内存映射
CPU通过内存映射IO(MMIO)访问其对应的APIC,这是因为访问I/O设备(例如APIC)的寄存器需要特定的地址空间。MMIO允许CPU将I/O设备的寄存器映射到物理内存的某些区域,使得CPU可以像访问内存一样通过读取或写入特定的 物理内存地址来访问这些寄存器。
APIC包含一系列寄存器,用于控制和管理中断处理、中断分发等功能。CPU需要与APIC进行通信,以便正确地处理和响应系统中的中断。通过MMIO,CPU可以通过加载和存储指令来读取和写入特定的物理内存地址,从而与APIC的寄存器进行交互,执行中断控制和处理的操作。这种方式简化了CPU与APIC之间的通信,并使其能够有效地管理中断。
处理器使用内存映射I/O(MMIO)访问其LAPIC。在MMIO中,物理内存的一部分被硬连接到某些I/O设备的寄存器,因此通常用于访问内存的相同加载/存储指令也可用于访问设备寄存器。你已经看到了物理地址0xA0000处的一个IO空间(我们用它来写入VGA显示缓冲区)。LAPIC位于以物理地址0xFE000000(距离4GB短32MB)开头的一个空间中,因此我们无法使用KERNBASE的常规直接映射来访问它。JOS虚拟内存映射在MMIOBASE处留下了一个4MB的间隙,因此我们有一个地方可以映射设备。由于后续实验引入了更多的MMIO区域,你将编写一个简单的函数,从这个区域分配空间并将设备内存映射到其中。
-
对上面那段话的解释
这段话涉及到在操作系统中访问设备寄存器的问题。让我更详细地解释一下:
- 内存映射 I/O(MMIO):在计算机系统中,设备(如网卡、显卡、声卡等)通常有一组寄存器,用于控制设备的功能和状态。这些寄存器通常被映射到物理内存的某些地址范围内,这种技术称为内存映射 I/O(MMIO)。通过在这些地址范围内读写数据,可以控制设备的行为。
- LAPIC:LAPIC 是本地高级可编程中断控制器,是计算机系统中用于管理中断的重要组件之一。它负责处理来自处理器核心和其他外设的中断信号,并将它们路由到正确的处理器核心。
- LAPIC 的物理地址:LAPIC 的寄存器通常位于物理内存的特定地址范围内。在 JOS 中,LAPIC 位于物理地址
0xFE000000
开头的空间中。 - 虚拟内存映射:操作系统需要提供一种机制,使得应用程序可以通过虚拟地址来访问设备寄存器,而不需要直接操作物理地址。为了实现这一点,操作系统会将设备寄存器的物理地址映射到虚拟地址空间中的某个地址范围。
- MMIOBASE 的用途:在 JOS 中,为了方便访问设备寄存器,操作系统在虚拟地址空间中预留了一个 4MB 的空间,即
MMIOBASE
到MMIOBASE + MMIOLIM
之间的范围。这个范围可以用来映射设备寄存器。 - 编写映射函数:为了将设备寄存器映射到虚拟地址空间中的这个区域,操作系统需要编写一个简单的函数,该函数负责从
MMIOBASE
开始分配空间,并将设备内存映射到其中。这样一来,应用程序就可以通过虚拟地址来访问设备寄存器了。
-
MMIOBASE
是的,通常MMIOBASE是位于操作系统内核中的一个常量或宏定义,用于指示内核在虚拟地址空间中用于内存映射I/O的基地址。这个地址通常在操作系统的内核代码中定义,用于建立虚拟地址空间中设备寄存器的映射关系。
操作系统内核需要负责管理设备的访问,而MMIOBASE作为设备I/O内存映射的起始地址,允许内核通过特定的地址范围来访问和控制设备的寄存器。通过将外设的物理地址映射到MMIOBASE开始的虚拟地址范围内,内核可以使用普通的内存操作指令来进行设备的读写操作,从而与外设进行通信和控制。
因此,MMIOBASE通常是在操作系统内核中定义的常量或宏,用于指示虚拟地址空间中用于设备I/O内存映射的起始位置。
**Exercise 1.**在kern/pmap.c中实现mmio_map_region函数。要查看其使用方法,请查看kern/lapic.c中lapic_init函数的开头部分。在运行mmio_map_region的测试之前,你还需要完成下一个练习。
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).
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).
//
// 从base开始保留大小为size的虚拟内存空间,并将物理页面[pa, pa+size)映射到虚拟地址[base, base+size)。
// 由于这是设备内存而不是常规DRAM(动态随机存取存储器),你需要告诉CPU不能对这块内存进行缓存访问。
// 幸运的是,页表提供了用于此目的的位;只需在创建映射时使用PTE_PCD|PTE_PWT(表示禁用缓存和写直通)以及PTE_W(可写)。
// (如果你对此感兴趣,可以参考IA32卷3A第10.5节以获取更多详细信息。)
//
// 请确保将size舍入为PGSIZE的倍数,并处理如果此保留会溢出MMIOLIM(如果发生这种情况,只需触发panic即可)。
// Hint: The staff solution uses boot_map_region.
//
// Your code here:
size = ROUNDUP(pa+size, PGSIZE);
pa = ROUNDDOWN(pa, PGSIZE);
size -= pa;
if (base+size >= MMIOLIM) panic("not enough memory");
boot_map_region(kern_pgdir, base, size, pa, PTE_PCD|PTE_PWT|PTE_W);//映射一个物理地址范围到一个给定的虚拟地址范围
base += size;
return (void*) (base - size);//作用:通过修改pgdir指向的树,将[base, va+size)对应的虚拟地址空间映射到物理地址空间[pa, pa+size)。base和pa都是页对齐的
}
Application Processor Bootstrap
在启动AP之前,BSP(引导处理器)应首先收集有关多处理器系统的信息,如CPU的总数、它们的APIC ID和LAPIC单元的MMIO地址。kern/mpconfig.c中的mp_init()函数通过读取驻留在BIOS内存区域中的MP配置表来检索此信息。该函数会在进入内核后被i386_init()调用,主要作用就是读取mp configuration table中保存的CPU信息,初始化cpus数组,ncpu(总共多少可用CPU),bootcpu指针(指向BSP对应的CpuInfo结构)。
boot_aps()函数(位于kern/init.c中)驱动AP引导过程。APs在实模式下启动,类似于引导加载程序在boot/boot.S中的启动方式,因此boot_aps()函数将AP入口代码(kern/mpentry.S)复制到实模式可寻址的内存位置。与引导加载程序不同,我们可以控制AP将要执行代码的位置;我们将入口代码复制到0x7000(MPENTRY_PADDR),但是任何未使用的、页面对齐的物理地址低于640KB的地址都可以工作。
之后,boot_aps()函数逐个激活AP,通过向相应AP的LAPIC单元发送STARTUP IPIs(启动IPI),并附上AP应该开始运行其入口代码的初始CS:IP地址(在我们的情况下是MPENTRY_PADDR)。kern/mpentry.S中的入口代码与boot/boot.S相似。经过一些简要的设置后,它将AP置于启用分页的保护模式下,然后调用C设置例程mp_main()(也位于kern/init.c中)。boot_aps()函数等待AP在其struct CpuInfo的cpu_status字段中发出CPU_STARTED标志,然后再继续唤醒下一个AP。
-
上述的清晰流程
这段话描述的是在多处理器系统中,特别是使用对称多处理(SMP)架构的系统中,引导处理器(Bootstrap Processor,简称 BSP)如何初始化系统并启动应用处理器(Application Processor,简称 AP)的过程。以下是详细解释:
1. 收集多处理器系统信息
mp_init()
函数:在kern/mpconfig.c
中的mp_init()
函数负责读取多处理器配置信息。它通过解析 BIOS 提供的 MP 配置表来获取关于 CPU 的信息,如 CPU 的总数、APIC ID、LAPIC 的内存映射 I/O (MMIO) 地址等。- 函数调用时机:
mp_init()
通常在操作系统的早期初始化阶段,比如在i386_init()
函数中被调用。
2. 启动应用处理器(AP)
boot_aps()
函数:位于kern/init.c
的boot_aps()
函数负责启动 AP。这个过程包括将 AP 的启动代码复制到实模式下可访问的内存位置。- AP 入口代码位置:AP 的入口代码被复制到
0x7000
(MPENTRY_PADDR
),这是一个位于低于 640KB 的、未被使用的、页面对齐的物理地址。
3. AP 的启动过程
- 发送启动 IPIs:
boot_aps()
函数通过向 AP 的 LAPIC 单元发送启动中断程序请求(Startup Inter-Processor Interrupts,简称 STARTUP IPIs)来激活 AP。这些 IPIs 指示 AP 从指定的初始代码地址开始执行。 - AP 入口代码执行:
kern/mpentry.S
中的代码类似于引导加载器的启动代码。它首先执行一些基本的设置,然后将 AP 切换到启用分页的保护模式,并调用 C 函数mp_main()
。
4. 同步 AP 启动
- 等待 CPU_STARTED 标志:
boot_aps()
函数在继续启动下一个 AP 之前,会等待当前 AP 在其struct CpuInfo
的cpu_status
字段中设置CPU_STARTED
标志,以确认该 AP 已成功启动并运行。
总结
这个过程描述了在多处理器系统中,如何由 BSP 启动并初始化 AP,确保所有的处理器都正确地进入操作系统控制的保护模式,并准备好执行多任务操作。这是多处理器系统初始化的关键步骤,确保系统中的所有 CPU 都能够协同工作。
**Exercise 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()测试,我们将很快修复)。
void
page_init(void)
{
...
size_t i;
for (i = 1; i < MPENTRY_PADDR/PGSIZE; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
...
}
Question
将kern/mpentry.S和boot/boot.S进行并排比较。记住,kern/mpentry.S被编译和链接以在KERNBASE之上运行,就像内核中的其他所有内容一样。宏MPBOOTPHYS的目的是什么?为什么在kern/mpentry.S中是必需的,但在boot/boot.S中不是?换句话说,如果在kern/mpentry.S中省略了这个宏,会出现什么问题? 提示:回想一下我们在实验1中讨论过的链接地址和加载地址之间的差异。
Per-CPU State and Initialization
-
struct 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 };
当编写一个多处理器操作系统时,区分每个处理器私有的per-CPU状态和整个系统共享的全局状态是很重要的。kern/cpu.h定义了大部分per-CPU状态,包括存储每个CPU变量的struct CpuInfo。cpunum()始终返回调用它的CPU的ID,这可以用作像cpus这样的数组的索引。另外,宏thiscpu是当前CPU的struct CpuInfo的简写。
以下是你应该了解的per-CPU状态:
-
每个CPU的内核栈。
因为多个CPU可以同时陷入内核,所以我们需要为每个处理器单独设置一个内核栈,以防止它们相互干扰。数组percpu_kstacks[NCPU][KSTKSIZE]为NCPU的内核栈预留了空间。
在实验2中,你将bootstack指向的物理内存映射为BSP(引导处理器)的内核栈,位于KSTACKTOP的正下方。类似地,在这个实验中,你将每个CPU的内核栈映射到这个区域,通过保护页面在它们之间起到缓冲作用。CPU 0的栈仍然从KSTACKTOP向下增长;CPU 1的栈将从CPU 0的栈底部以下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上执行的环境(代码运行的CPU)。
-
每个CPU的系统寄存器。 所有寄存器,包括系统寄存器,都是每个CPU私有的。因此,初始化这些寄存器的指令(例如lcr3()、ltr()、lgdt()、lidt()等)必须在每个CPU上执行一次。为此定义了env_init_percpu()和trap_init_percpu()函数。
-
CpuInfo和Env关系
此外,如果你在之前的实验中在解决挑战问题时添加了任何额外的每个CPU状态,或执行了任何额外的特定于CPU的初始化(比如,在CPU寄存器中设置新的位),请确保在每个CPU上都复制这些操作!
**Exercise 3.**修改mem_init_mp()(位于kern/pmap.c中),将每个CPU的栈映射到从KSTACKTOP开始,如inc/memlayout.h中所示。每个栈的大小为KSTKSIZE字节,加上KSTKGAP字节的未映射保护页面。你的代码应该通过check_kern_pgdir()中的新检查。
解决:本质上是修改kern_pdir指向的页目录和页表,按照inc/memlayout.h中的结构进行映射即可。
static void
mem_init_mp(void)
{
// 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, kstacktop_i)
// -- 由物理内存支持
// * [kstacktop_i - (KSTKSIZE + KSTKGAP), kstacktop_i - KSTKSIZE)
// -- 不支持;因此,如果内核溢出其栈,它将产生错误而不是覆盖另一个CPU的栈。这被称为“guard page”(保护页面)。
// 权限:内核读写,用户不可访问
//
// LAB 4: Your code here:
for (int i = 0; i < NCPU; i++) {
boot_map_region(kern_pgdir,
KSTACKTOP - KSTKSIZE - i * (KSTKSIZE + KSTKGAP),
KSTKSIZE,
PADDR(percpu_kstacks[i]),
PTE_W);
}
}
> **Exercise 4.** trap_init_percpu() 函数(位于 kern/trap.c)中的代码初始化了BSP(引导处理器)的TSS和TSS描述符。在实验3中它工作正常,但在其他CPU上运行时是不正确的。修改代码,使其可以在所有CPU上运行。(注意:你的新代码不应再使用全局变量 ts。)
>
```cpp
void
trap_init_percpu(void)
{
int cid = thiscpu->cpu_id; // 获取当前 CPU 的本地 APIC ID
thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - cid * (KSTKSIZE + KSTKGAP); // 设置该 CPU 的内核栈顶地址
thiscpu->cpu_ts.ts_ss0 = GD_KD; // 设置内核数据段描述符
// 在全局描述符表(GDT)中设置任务状态段(TSS)的描述符
gdt[(GD_TSS0 >> 3)+cid] = SEG16(STS_T32A, (uint32_t) (&(thiscpu->cpu_ts)),
sizeof(struct Taskstate), 0);
gdt[(GD_TSS0 >> 3)+cid].sd_s = 0; // 清除描述符的系统标志位,表示这是一个系统段
ltr(GD_TSS0+8*cid); // 加载任务状态段寄存器(TR)
lidt(&idt_pd); // 加载中断描述符表寄存器(IDTR)
}
当你完成上述练习后,使用 make qemu CPUS=4
(或 make qemu-nox CPUS=4
)在 QEMU 中以 4 个 CPU 运行 JOS,你应该会看到如下输出:
...
Physical memory: 66556K available, base = 640K, extended = 65532K
check_page_alloc() succeeded!
check_page() succeeded!
check_kern_pgdir() succeeded!
check_page_installed_pgdir() succeeded!
SMP: CPU 0 found 4 CPU(s)
enabled interrupts: 1 2
SMP: CPU 1 starting
SMP: CPU 2 starting
SMP: CPU 3 starting
Locking
我们当前的代码在 mp_main() 中初始化完AP后就会进入自旋状态。在让AP继续执行之前,我们需要首先解决多个CPU同时运行内核代码时可能出现的竞态条件问题。最简单的解决方式是使用一个大内核锁"big kernel lock”。大内核锁是一个全局锁,在环境进入内核模式时持有,在环境返回用户模式时释放。在这种模型下,处于用户模式的环境可以在任何可用的CPU上并发运行,但只有一个环境可以运行在内核模式下;任何其他试图进入内核模式的环境都将被强制等待。
锁的数据结构在kern/spinlock.h中:
struct spinlock {
unsigned locked; // Is the lock held?
};
我们的获取锁,释放锁的操作在kern/spinlock.c中
void
spin_lock(struct spinlock *lk)
{
// xchg 操作是原子的。
// 它还会序列化操作,确保在获取锁之后的读操作不会被重排到它之前执行。
while (xchg(&lk->locked, 1) != 0) // 见:<https://pdos.csail.mit.edu/6.828/2018/xv6/book-rev11.pdf> 第 4 章
asm volatile ("pause"); // 在忙等待期间执行 'pause' 指令,有助于减少功耗和延迟
}
void
spin_unlock(struct spinlock *lk)
{
// xchg 指令是原子的(即使用了 "lock" 前缀),
// 对于任何与同一内存地址相关的指令,x86 CPU 不会对加载/存储指令进行重排序
xchg(&lk->locked, 0); // 将锁状态置为 0,表示释放锁
}
static inline uint32_t
xchg(volatile uint32_t *addr, uint32_t newval)//用于原子地交换一个内存位置的值与一个新值,并返回原始值
{
uint32_t result;
// 使用 "+m" 表示一个读-修改-写操作数。
// asm volatile 用于嵌入汇编代码。
// "lock" 前缀确保 xchg 指令是原子操作。
// x86 CPU 不会对 lock 指令之后的指令进行重排序。
asm volatile("lock; xchgl %0, %1"
: "+m" (*addr), "=a" (result) // 输入输出参数:"+m" 表示 addr 是读-修改-写操作的内存操作数,"=a" 表示 result 是输出操作数
: "1" (newval) // 输入参数:"1" 表示 newval 使用和 result 相同的寄存器约束(因为 result 是输出操作数)
: "cc"); // clobbered 寄存器:"cc" 表示可能影响条件码寄存器(Condition Codes)
return result; // 返回交换前的原始值
}
kern/spinlock.h 声明了大内核锁,即 kernel_lock。它还提供了 lock_kernel() 和 unlock_kernel(),用于快速获取和释放锁。你应该在四个位置应用大内核锁:
-
在 i386_init() 中,在 BSP 唤醒其他 CPU 之前获取该锁。
-
在 mp_main() 中,在初始化 AP 后获取锁,并调用 sched_yield() 来开始在该 AP 上运行环境。
-
在 trap() 中,当从用户模式陷入时获取锁。要确定陷入是发生在用户模式还是内核模式,请检查 tf_cs 寄存器的低位。
-
在 env_run() 中,在切换到用户模式之前释放锁。不要太早或太晚释放锁,否则可能会出现竞态条件或死锁。
-
解释
这段话描述的是在多处理器系统中,特别是在使用“大内核锁”(Big Kernel Lock)同步机制的操作系统内核中,如何在不同的函数和阶段中正确地获取和释放这个锁。这些措施旨在确保在内核中运行的代码之间不会发生竞态条件,同时也确保系统的正确运行和性能。以下是对这段话的详细解释:
1. 在
i386_init()
中获取锁- 初始化阶段:在引导处理器(BSP)初始化系统的过程中(即在
i386_init()
函数中),应该获取大内核锁。这是因为在这个阶段,BSP 可能会执行一些不应该被并行执行的操作,比如初始化数据结构或硬件设备。
2. 在
mp_main()
中获取锁- 应用处理器初始化:当一个应用处理器(AP)被唤醒并执行
mp_main()
函数时,它也需要获取大内核锁。在完成初始化后,AP 可以调用sched_yield()
函数来开始在该处理器上调度和运行环境(进程或线程)。
3. 在
trap()
中获取锁- 陷阱处理:当从用户模式陷入内核模式时(如处理中断或异常),需要在
trap()
函数中获取锁。可以通过检查陷阱帧(trap frame)中的tf_cs
寄存器的低位来确定当前是在用户模式还是内核模式。这一步骤确保了当处理器从用户态陷入内核态时,内核代码的执行是互斥的。
4. 在
env_run()
中释放锁- 环境切换:在切换到用户模式之前(即在
env_run()
函数中),需要释放大内核锁。这一步骤至关重要,因为如果锁保持被持有状态,那么其他试图进入内核模式的环境将无法执行,可能导致死锁。同时,释放锁的时机也很重要,以避免竞态条件。
总结
这些措施展示了在多处理器系统中,特别是在使用大内核锁的操作系统内核中,如何在关键的代码段中正确地管理这个全局锁。正确地获取和释放锁对于保证系统的稳定性、避免死锁和竞态条件至关重要。
- 初始化阶段:在引导处理器(BSP)初始化系统的过程中(即在
Exercise 5. 按照上述描述,在适当的位置调用 lock_kernel() 和 unlock_kernel() 来应用大内核锁。
//i386_init
lock_kernel();
boot_aps();
//mp_main
lock_kernel();
sched_yield();
//trap
if ((tf->tf_cs & 3) == 3) {
lock_kernel();
assert(curenv);
......
}
//env_run
lcr3(PADDR(curenv->env_pgdir));
unlock_kernel();
env_pop_tf(&(curenv->env_tf));
如何测试锁的正确性?在当前阶段你还无法进行测试!但在完成下一个练习中实现调度器后,你就可以进行测试了。
Question
big kernel lock似乎已经确保每次仅仅一个CPU能运行内核代码, 为什么我们仍然需要为每个CPU设定一个内核栈
因为每个CPU进入内核,其压栈的数据可能不一样,同时此CPU下一次再进入内核时可能需要用到之前的data。因为在_alltraps到 lock_kernel()的过程中,进程已经切换到了内核态,但并没有上内核锁,此时如果有其他CPU进入内核,如果用同一个内核栈,则_alltraps中保存的上下文信息会被破坏,所以即使有大内核栈,CPU也不能用用同一个内核栈。同样的,解锁也是在内核态内解锁,在解锁到真正返回用户态这段过程中,也存在上述这种情况。
Challenge!
大内核锁(big kernel lock)简单易用。然而,它在内核模式下消除了所有并发性。大多数现代操作系统使用不同的锁来保护其共享状态的不同部分,这种方法称为细粒度锁(fine-grained locking)。细粒度锁可以显著提高性能,但实现起来更加困难且容易出错。如果你足够勇敢,可以放弃大内核锁,在 JOS 中实现并发性!
你可以自行决定锁的粒度(保护的数据量)。作为提示,你可以考虑使用自旋锁来确保对 JOS 内核中以下共享组件的独占访问:
- 页分配器(page allocator)。
- 控制台驱动程序(console driver)。
- 调度器(scheduler)。
- 在第 C 部分中将要实现的进程间通信(IPC)状态。
Round-Robin Scheduling
你在这个实验中的下一个任务是修改 JOS 内核,使其能够以“轮转法”(round-robin)的方式在多个环境之间交替执行。在 JOS 中,轮转法调度的工作原理如下:
- 新的
kern/sched.c
中的sched_yield()
函数负责选择要运行的新环境。它通过循环方式顺序搜索envs[]
数组,从上一个运行的环境后面开始(如果之前没有运行的环境,则从数组的开头开始),选择数组中第一个状态为ENV_RUNNABLE
(见inc/env.h
)的环境,并调用env_run()
来跳转到该环境。 sched_yield()
绝不能同时在两个 CPU 上运行同一个环境。它可以通过一个环境的状态为ENV_RUNNING
来判断当前某个 CPU 上是否正在运行该环境(可能是当前 CPU)。- 我们已经为您实现了一个新的系统调用
sys_yield()
,用户环境可以调用该系统调用来调用内核的sched_yield()
函数,从而自愿将 CPU 让给另一个环境。
Exercise 6.
根据上述描述,在
sched_yield()
函数中实现轮转法调度。不要忘记修改syscall()
函数以调度sys_yield()
。确保在
mp_main
中调用sched_yield()
。修改
kern/init.c
以创建三个(或更多!)所有运行user/yield.c
程序的环境。运行
make qemu
。在终止之前,您应该看到环境在彼此之间来回切换五次,如下所示:还可以通过多个 CPU 进行测试:
make qemu CPUS=2
。... Hello, I am environment 00001000. Hello, I am environment 00001001. Hello, I am environment 00001002. Back in environment 00001000, iteration 0. Back in environment 00001001, iteration 0. Back in environment 00001002, iteration 0. Back in environment 00001000, iteration 1. Back in environment 00001001, iteration 1. Back in environment 00001002, iteration 1. ... yield 程序退出后,在系统中将不再有可运行的环境,调度程序应该调用 JOS 内核监视器。如果发生任何异常情况,请在继续之前修复代码。
-
sched_yield代码如下所示。经过最后PART_A疑问部分第三问的分析,实际上我们可以去掉return这条语句。
void sched_yield(void) { struct Env *idle; int start = 0; int j; // 如果当前有正在运行的环境,从当前环境的下一个环境开始搜索 if (curenv) { start = ENVX(curenv->env_id) + 1; // 从当前Env结构的下一个开始 } // 遍历所有Env结构 for (int i = 0; i < NENV; i++) { j = (start + i) % NENV; // 循环遍历 // 如果找到一个可运行的环境,则运行它 if (envs[j].env_status == ENV_RUNNABLE) { env_run(&envs[j]); } } // 如果当前环境仍然是可运行状态,则继续运行当前环境 // 这是必要的,比如在只有一个环境的情况下,如果没有这个判断,那么这个CPU将会停机 if (curenv && curenv->env_status == ENV_RUNNING) { env_run(curenv); } // 如果没有找到可运行的环境,调用 sched_halt 来停止调度器 // 注意:sched_halt 函数永远不会返回 sched_halt(); }
-
kern/syscall.c
中的syscall中加入一个casecase SYS_yield: sys_yield(); break;
-
init
中创建用户环境ENV_CREATE(user_yield, ENV_TYPE_USER); ENV_CREATE(user_yield, ENV_TYPE_USER); ENV_CREATE(user_yield, ENV_TYPE_USER);
-
make qemu 与 make qemu CPUS=2都可以出现以下结果。
check_page_installed_pgdir() succeeded! SMP: CPU 0 found 1 CPU(s) enabled interrupts: 1 2 ... Hello, I am environment 00001000. ... Hello, I am environment 00001003. Back in environment 00001000, iteration 0. ... Back in environment 00001000, iteration 1. ... Back in environment 00001002, iteration 3. ... Back in environment 00001000, iteration 4. All done in environment 00001000. [00001000] exiting gracefully [00001000] free env 00001000 Back in environment 00001001, iteration 4. All done in environment 00001001. ... All done in environment 00001003. [00001003] exiting gracefully [00001003] free env 00001003 No runnable environments in the system! Welcome to the JOS kernel monitor!
Question
在你的
env_run()
实现中,应该调用了lcr3()
函数。在调用lcr3()
之前和之后,你的代码应该(至少应该)引用了变量e
,它是传递给env_run
函数的参数。在加载%cr3
寄存器时,MMU 使用的寻址上下文会立即改变。但是一个虚拟地址(即e
)相对于特定的地址上下文具有意义——地址上下文指定了虚拟地址映射到的物理地址。为什么指针e
可以在地址切换之前和之后都被解引用?每当内核从一个环境切换到另一个环境时,它必须确保保存旧环境的寄存器,以便稍后能够正确地恢复它们。为什么需要这样做?这个过程发生在哪里?
因为当前是运行在系统内核中的,而每个进程的页表中都是存在内核映射的。每个进程页表中虚拟地址高于UTOP之上的地方,只有UVPT不一样,其余的都是一样的,只不过在用户态下是看不到的。所以虽然这个时候的页表换成了下一个要运行的进程的页表,但是curenv的地址没变,映射也没变,还是依然有效的。
-
解释
在
env_run
函数中调用lcr3
函数来切换到新环境的页目录时,确实会立即改变内存的寻址上下文,因为lcr3
加载了新的页表基址到%cr3
寄存器。这个操作会改变虚拟地址到物理地址的映射。尽管如此,指针e
依然可以在地址切换之前和之后被解引用,原因在于操作系统内核地址空间的布局和映射方式。以下是详细解释:内核地址空间的映射
- 内核映射的一致性:在许多操作系统中,内核的地址空间(包括内核代码、数据和内核管理的数据结构)在所有进程的页表中都有相同的映射。这意味着无论哪个进程的页表被加载到
%cr3
,内核地址空间的映射方式都保持不变。
指针
e
的有效性- 指向内核数据结构:
e
是一个指向某个环境(如进程控制块)的指针,而这些环境结构通常存储在内核空间中。由于内核空间在所有进程的页表中映射方式相同,所以e
指向的内存在地址切换前后依然有效。
页表切换对
e
的影响- 地址切换前后:在调用
lcr3
切换页表之前,e
指向的是当前环境的相关信息。当页表切换发生后,即使虚拟地址到物理地址的映射发生了变化,e
依然指向同一块内存区域,因为内核地址空间的映射未发生改变。
总结
因此,即使在
lcr3
调用改变了地址映射的上下文之后,指针e
仍然可以被解引用,因为它指向的是内核空间中的数据,这部分空间在所有页表中都有一致的映射。这种设计是操作系统内核管理多个进程时的一个关键特性,它允许内核代码在切换不同进程的上下文时继续正常工作。 - 内核映射的一致性:在许多操作系统中,内核的地址空间(包括内核代码、数据和内核管理的数据结构)在所有进程的页表中都有相同的映射。这意味着无论哪个进程的页表被加载到
因为不保存下来就无法正确地恢复到原来的环境。用户进程之间的切换,会调用系统调用sched_yield();用户态陷入到内核态,可以通过中断、异常、系统调用;这样的切换之处都是要在系统栈上建立用户态的TrapFrame,在进入trap()函数后,语句curenv->env_tf = *tf;将内核栈上需要保存的寄存器的状态实际保存在用户环境的env_tf域中。
Challenge!
挑战!在内核中添加一个不太平凡的调度策略,比如一个固定优先级调度程序,允许为每个环境分配一个优先级,并确保始终优先选择高优先级的环境而不是低优先级的环境。如果你感觉非常有冒险精神,可以尝试实现类似于 Unix 的可调优先级调度程序,甚至是抽奖调度(lottery scheduling)或步幅调度(stride scheduling)。(在 Google 中查找“lottery scheduling”和“stride scheduling”)
编写一个或两个测试程序来验证你的调度算法是否正常工作(即正确的环境按正确的顺序运行)。当你在本实验室的部分 B 和 C 中实现了 fork() 和 IPC 后,编写这些测试程序可能会更容易一些。
Challenge!
挑战!JOS 内核目前不允许应用程序使用 x86 处理器的 x87 浮点单元(FPU)、MMX 指令或流式多媒体扩展(SSE)。扩展
Env
结构以提供处理器浮点状态的保存区域,并扩展上下文切换代码,以在从一个环境切换到另一个环境时正确保存和恢复这个状态。FXSAVE 和 FXRSTOR 指令可能会有用,但请注意这些指令不在旧的 i386 用户手册中,因为它们是在更近期的处理器中引入的。编写一个用户级测试程序,使用浮点数执行一些有趣的操作。System Calls for Environment Creation
尽管您的内核现在能够运行和在多个用户级环境之间切换,但它仍然局限于运行内核最初设置的环境。您现在将实现必要的 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: 将指定环境的状态设置为 ENV_RUNNABLE 或 ENV_NOT_RUNNABLE。这个系统调用通常用于在完全初始化其地址空间和寄存器状态后标记一个新环境为准备运行。
- sys_page_alloc: 在给定环境的地址空间中分配一页物理内存并将其映射到给定的虚拟地址处。
- sys_page_map: 从一个环境的地址空间中复制一个页面映射(不是页面的内容!)到另一个环境,以便留下一个内存共享安排,使新的和旧的映射都引用相同的物理内存页。
- sys_page_unmap: 在给定环境中取消映射指定虚拟地址处的一个页面。
对于上述所有接受环境 ID 的系统调用,JOS 内核支持一个约定:0 的值表示“当前环境”。这个约定在 kern/env.c 中的 envid2env() 中实现。
我们在测试程序 user/dumbfork.c 中提供了一个非常简单的类似 Unix 的 fork() 的实现。这个测试程序使用上述系统调用来创建并运行一个具有自己地址空间副本的子环境。然后,这两个环境像前一个练习中那样相互切换,父进程在 10 次迭代后退出,而子进程在 20 次迭代后退出。
**Exercise 7.**在 kern/syscall.c 中实现上述描述的系统调用,并确保 syscall() 调用这些系统调用。您需要使用 kern/pmap.c 和 kern/env.c 中的各种函数,特别是 envid2env()。暂时,在调用 envid2env() 时,将 1 传递给 checkperm 参数。确保检查任何无效的系统调用参数,在这种情况下返回 -E_INVAL。在使用 user/dumbfork 测试 JOS 内核之前,请确保它正常运行。
sys_exofork(void):
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. // LAB 4: Your code here. struct Env *e; int ret = env_alloc(&e, curenv->env_id); // 分配一个新的环境(进程)结构 if (ret < 0) { return ret; // 如果分配失败,返回错误代码 } e->env_tf = curenv->env_tf; // 复制当前环境(父环境)的寄存器状态到新环境(子环境) e->env_status = ENV_NOT_RUNNABLE; // 设置新环境的状态为“不可运行” e->env_tf.tf_regs.reg_eax = 0; // 设置新环境的返回值寄存器(eax)为 0 return e->env_id; // 返回新环境的环境标识符 }
用于创建一个新的环境(在类 Unix 系统中,环境可以理解为进程)。这个函数的主要目的是复制当前环境(即父环境)来创建一个新的环境(即子环境),但新环境的状态被设置为不可运行(
ENV_NOT_RUNNABLE
),直到它被显式地设置为可运行sys_env_set_status(envid_t envid, int status):
static int sys_env_set_status(envid_t envid, int status) { // 检查 status 是否为有效的状态值 if (status != ENV_NOT_RUNNABLE && status != ENV_RUNNABLE) return -E_INVAL; struct Env *e; // 将环境ID转换为 Env 结构的指针,并检查当前环境是否有权限设置该状态 int ret = envid2env(envid, &e, 1); if (ret < 0) { return ret; // 如果转换失败或没有权限,返回错误代码 } e->env_status = status; // 设置环境的状态 return 0; // 返回成功 }
它是一个系统调用函数,用于设置一个特定环境(进程)的状态。这个函数允许环境(进程)改变另一个环境的运行状态。
sys_page_alloc(envid_t envid, void *va, int perm):
static int sys_page_alloc(envid_t envid, void *va, int perm) { struct Env *e; // 根据环境ID (envid) 找出需要操作的环境结构 int ret = envid2env(envid, &e, 1); if (ret) return ret; // 如果找不到环境,返回错误 // 检查虚拟地址 va 是否有效和正确对齐 if ((va >= (void*)UTOP) || (ROUNDDOWN(va, PGSIZE) != va)) return -E_INVAL; // 检查权限标志 perm 是否合法(必须包含 PTE_U 和 PTE_P) int flag = PTE_U | PTE_P; if ((perm & flag) != flag) return -E_INVAL; struct PageInfo *pg = page_alloc(1); // 分配一个新的物理页 if (!pg) return -E_NO_MEM; // 如果分配失败,返回内存不足的错误 // 将新分配的物理页映射到环境的虚拟地址空间 ret = page_insert(e->env_pgdir, pg, va, perm); if (ret) { page_free(pg); // 如果映射失败,释放之前分配的物理页 return ret; } return 0; // 成功返回 }
用于为特定的环境(进程)分配一个新的物理页面,并将其映射到该环境的虚拟地址空间。
sys_page_map(envid_t srcenvid, void *srcva,envid_t dstenvid, void *dstva, int perm):
static int sys_page_map(envid_t srcenvid, void *srcva, envid_t dstenvid, void *dstva, int perm) { struct Env *se, *de; // 将源环境和目标环境的ID转换为环境结构指针 int ret = envid2env(srcenvid, &se, 1); if (ret) return ret; // 如果源环境ID无效,返回错误 ret = envid2env(dstenvid, &de, 1); if (ret) return ret; // 如果目标环境ID无效,返回错误 // 检查源地址和目标地址是否有效和对齐 if (srcva >= (void*)UTOP || dstva >= (void*)UTOP || ROUNDDOWN(srcva, PGSIZE) != srcva || ROUNDDOWN(dstva, PGSIZE) != dstva) return -E_INVAL; // 查找源虚拟地址对应的页面 pte_t *pte; struct PageInfo *pg = page_lookup(se->env_pgdir, srcva, &pte); if (!pg) return -E_INVAL; // 如果页面不存在,返回错误 // 检查权限标志是否合法 int flag = PTE_U | PTE_P; if ((perm & flag) != flag) return -E_INVAL; // 如果需要写权限,但源页面是只读的,则返回错误 if (((*pte & PTE_W) == 0) && (perm & PTE_W)) return -E_INVAL; // 将页面插入到目标环境的地址空间 ret = page_insert(de->env_pgdir, pg, dstva, perm); return ret; // 返回操作结果 }
用于将一个环境的内存页面映射到另一个环境的地址空间。
sys_page_unmap(envid_t envid, void *va):
static int sys_page_unmap(envid_t envid, void *va) { struct Env *env; // 将环境ID转换为环境结构指针 int ret = envid2env(envid, &env, 1); if (ret) return ret; // 如果环境ID无效,返回错误 // 检查虚拟地址 va 是否有效和对齐 if ((va >= (void*)UTOP) || (ROUNDDOWN(va, PGSIZE) != va)) return -E_INVAL; // 取消映射指定的虚拟地址 page_remove(env->env_pgdir, va); return 0; // 返回成功 }
用于取消映射一个环境(进程)地址空间中的特定虚拟地址
***Challenge!***添加额外的系统调用以读取现有环境的全部关键状态以及设置它所需的状态。然后,实现一个用户模式程序,该程序创建一个子环境,运行一段时间(例如,运行几次 sys_yield()),然后对子环境进行完整的快照或检查点,接着再运行子环境一段时间,并最终将子环境恢复到检查点时的状态,并从那里继续运行。因此,您有效地从中间状态“重放”了子环境的执行。使子环境与用户进行一些交互,使用 sys_cgetc() 或 readline(),以便用户可以查看和修改其内部状态,并验证通过您的检查点/恢复,您可以让子环境产生选择性遗忘,使其“忘记”某个特定时刻发生的一切。
这完成了实验的A部分;确保在运行
make grade
时通过了所有A部分的测试,并像往常一样使用make handin
提交它。如果您试图弄清楚为什么特定的测试用例失败了,可以运行./grade-lab4 -v
,它会显示每个测试的内核构建和QEMU运行的输出,直到有一个测试失败为止。当测试失败时,脚本将停止,然后您可以检查jos.out
以查看内核实际打印了什么。Part B: Copy-on-Write Fork
如前所述,Unix提供了fork()系统调用作为其主要的进程创建原语。fork()系统调用将调用进程(父进程)的地址空间复制到新进程(子进程)中。
xv6 Unix通过将父进程的所有数据复制到为子进程分配的新页面中来实现fork()。这基本上是dumbfork()采用的相同方法。将父进程的地址空间复制到子进程中是fork()操作中最昂贵的部分。
然而,对fork()的调用通常会紧随着子进程中对exec()的调用,以用新程序替换子进程的内存。例如,这就是shell通常所做的。在这种情况下,将时间花费在复制父进程的地址空间上在很大程度上是浪费的,因为在调用exec()之前,子进程几乎不会使用它的内存。
因此,Unix的后续版本利用虚拟内存硬件,允许父进程和子进程共享映射到各自地址空间的内存,直到其中一个进程实际修改它。这种技术被称为写时复制(copy-on-write)。为了实现这一点,在fork()时,内核会将父进程到子进程的地址空间映射复制,而不是映射页面的内容,并同时将现在共享的页面标记为只读。当其中一个进程尝试写入这些共享页面中的一个时,该进程会发生页错误。此时,Unix内核意识到该页面实际上是一个“虚拟”或“写时复制”副本,因此它为发生错误的进程创建一个新的私有可写副本。通过这种方式,只有在实际写入页面时才会复制单个页面的内容。这个优化使得在子进程中的fork()后面跟着exec()变得更加便宜:子进程可能只需要在调用exec()之前复制一页(其堆栈的当前页)。
在本实验的下一部分中,您将在用户空间实现一个具有写时复制功能的“正确”的类Unix fork(),作为一个用户空间库例程。在用户空间实现fork()和写时复制支持的好处是内核保持简单,因此更有可能正确。它还允许个别用户模式程序定义其自己的fork()语义。一个希望有略有不同实现的程序(例如,昂贵的总是复制版本,如dumbfork(),或者父进程和子进程实际上在之后共享内存的版本)可以轻松提供自己的版本。
-
写时拷贝的示意图
想要实现写时拷贝的fork()需要先实现用户级别的缺页中断处理函数。
User-level page fault handling
用户级别的写时复制fork()需要知道写保护页面上的页面错误,这是您首先要实现的功能。写时复制只是用户级别页面错误处理的众多可能用途之一。
通常会设置地址空间,以便页面错误指示需要执行某些操作。例如,大多数Unix内核最初仅在新进程的堆栈区域中映射单个页面,并在进程的堆栈消耗增加并导致尚未映射的堆栈地址上发生页面错误时,后续“按需”分配并映射附加的堆栈页面。典型的Unix内核必须跟踪在进程空间的每个区域发生页面错误时要采取的操作。例如,在堆栈区域中发生错误通常会分配和映射新的物理内存页面。在程序的BSS区域中发生错误通常会分配一个新页面,将其填充为零,并映射它。在支持按需分页的可执行文件系统中,文本区域中的错误会从磁盘上读取相应的二进制页面,然后映射它。
内核需要跟踪的信息很多。与采用传统的Unix方法不同,您将在用户空间决定如何处理每个页面错误,因为在那里错误的影响较小。这种设计的附加好处是允许程序在定义其内存区域时具有很大的灵活性;稍后您将使用用户级别的页面错误处理来映射和访问基于磁盘的文件系统上的文件。
-
对上面那段话的解释
这段话讲述的是操作系统中“写时复制(copy-on-write, COW)”技术在用户级别(非内核级别)的实现和应用。这里解释了这种方法的基本概念和一些具体的应用场景。我将逐点解释:
- 写时复制(COW):这是一种资源管理技术,用于优化内存使用。当一个进程复制(如通过
fork()
)时,操作系统不会立即复制整个内存页面。相反,它只在进程尝试修改这些页面时才进行复制。这就需要操作系统能够感知何时页面被写入,这是您首先需要实现的功能。 - 用户级别页面错误处理:在操作系统中,当进程尝试访问尚未分配或有其他访问限制的内存时,会发生“页面错误”。在传统的Unix系统中,这些错误通常由内核处理。但在您的场景中,这些页面错误将在用户空间(即不是由操作系统核心,而是由用户级别的程序处理)处理。这种做法减少了内核的负担,允许用户空间程序更灵活地管理内存。
- 按需分配内存:在Unix系统中,通常只有少量内存被初始分配给进程(例如,为进程堆栈映射一个页面)。随着进程的运行,例如堆栈增长,系统会在需要时动态地分配更多内存。这个过程涉及到处理页面错误,以按需映射更多内存页面。
- 不同区域的页面错误处理:不同类型的内存区域在发生页面错误时需要不同的处理方式。例如,堆栈区域的错误可能会导致分配新的物理内存页面,而BSS区域(未初始化的数据)的错误可能会导致分配一个新页面并将其初始化为零。程序的文本区域(可执行代码)的错误可能涉及从磁盘读取数据并映射到内存中。
- 灵活性和文件系统应用:在用户空间处理页面错误提供了更多灵活性,允许程序自定义内存区域的管理。这种灵活性也使得程序可以更有效地利用基于磁盘的文件系统,例如,通过映射和访问存储在磁盘上的文件。
总结来说,这段话描述的是在用户空间实现写时复制和页面错误处理的方法,这种方法提供了更大的灵活性和效率,尤其是在处理内存和文件系统交互时。
- 写时复制(COW):这是一种资源管理技术,用于优化内存使用。当一个进程复制(如通过
Setting the Page Fault Handler
为了处理其自身的页面错误,用户环境需要向 JOS 内核注册一个页面错误处理入口点。用户环境通过新的 sys_env_set_pgfault_upcall 系统调用来注册其页面错误入口点。我们已经在 Env 结构中添加了一个新的成员 env_pgfault_upcall,用来记录这个信息。
Exercise 8.
实现 sys_env_set_pgfault_upcall 系统调用。在查找目标环境的环境 ID 时,一定要启用权限检查,因为这是一个“危险的”系统调用。
static int sys_env_set_pgfault_upcall(envid_t envid, void *func) { // LAB 4: Your code here. struct Env *env; // 定义一个 Env 结构体指针 int ret; // 用于存储函数返回值 // 使用 envid2env 函数根据环境 ID 获取对应的环境结构体指针 // 第三个参数为 1 表示启用权限检查 if ((ret = envid2env(envid, &env, 1)) < 0) { return ret; // 如果获取失败,返回错误代码 } // 将用户提供的函数地址设置为该环境的页面错误处理函数 env->env_pgfault_upcall = func; return 0; // 返回 0 表示成功 }
作用:这个函数通过接收一个环境 ID 和一个函数指针,将指定环境的页面错误处理函数设置为给定的函数。它首先检查调用者是否有权限对指定的环境进行操作,如果有权限,就更新该环境的
env_pgfault_upcall
字段为提供的函数地址。Normal and Exception Stacks in User Environments
在正常执行过程中,JOS 中的用户环境会在普通用户栈上运行:其 ESP 寄存器开始指向 USTACKTOP,它推送的栈数据位于 USTACKTOP-PGSIZE 和 USTACKTOP-1(包括)之间的页面上。然而,当用户模式下发生页面错误时,内核将在不同的栈上,即用户异常栈上,重新启动用户环境运行指定的用户级页面错误处理程序。本质上,我们将使 JOS 内核实现自动的“栈切换”,代表用户环境,这与 x86 处理器在从用户模式转换到内核模式时代表 JOS 实现栈切换的方式非常相似!
JOS 用户异常栈的大小也是一页,其顶部定义为虚拟地址 UXSTACKTOP,因此用户异常栈的有效字节从 UXSTACKTOP-PGSIZE 到 UXSTACKTOP-1(包括)。在此异常栈上运行时,用户级页面错误处理程序可以使用 JOS 的常规系统调用来映射新页面或调整映射,以修复最初导致页面错误的问题。然后,用户级页面错误处理程序通过汇编语言存根返回到原始栈上的错误代码。
每个希望支持用户级页面错误处理的用户环境都需要使用在第 A 部分介绍的 sys_page_alloc() 系统调用为其自己的异常栈分配内存。
-
对上面那段话的解释
这段话描述的是 JOS(一个教学用的简化操作系统)中用户级页面错误处理的机制。主要内容包括:
- 普通用户栈的使用:在正常运行时,JOS中的用户环境使用普通用户栈。ESP 寄存器(栈指针)初始指向 USTACKTOP,用户栈的数据位于 USTACKTOP-PGSIZE 和 USTACKTOP-1 之间的内存页面上。
- 用户模式下的页面错误处理:当用户模式下发生页面错误时,JOS 的内核将切换到一个不同的栈上执行用户级页面错误处理程序,这个栈被称为用户异常栈。
- 栈切换机制:JOS 内核自动实现了栈切换机制,这在概念上类似于 x86 处理器在从用户模式切换到内核模式时的栈切换。这意味着当发生页面错误时,用户环境不是在其常规栈上,而是在专门的异常栈上运行错误处理代码。
- 用户异常栈的定义:用户异常栈的大小也是一页(通常是 4KB),顶部位于虚拟地址 UXSTACKTOP。这个栈的有效内存范围是 UXSTACKTOP-PGSIZE 到 UXSTACKTOP-1。
- 异常栈上的操作:在用户异常栈上,页面错误处理程序可以执行正常的系统调用,比如映射新页面或调整现有映射,来解决引起页面错误的问题。处理完毕后,处理程序通过一个汇编语言存根(stub)返回到原始栈上继续执行之前的操作。
- 为异常栈分配内存:每个需要支持用户级页面错误处理的用户环境需要使用
sys_page_alloc()
系统调用为自己的异常栈分配内存。
总的来说,这段话介绍了在 JOS 操作系统中,用户环境如何处理用户模式下的页面错误,以及为此目的设置和使用用户异常栈的机制。
Invoking the User Page Fault Handler
当缺页中断发生时,内核会返回用户模式来处理该中断。我们需要一个用户异常栈,来模拟内核异常栈。JOS的用户异常栈被定义在虚拟地址UXSTACKTOP。
Invoking the User Page Fault Handler
缺页中断发生时会进入内核的trap(),然后分配page_fault_handler()来处理缺页中断。在该函数中应该做如下几件事:
- 判断curenv->env_pgfault_upcall是否设置,如果没有设置也就没办法修复,直接销毁该进程。
- 修改esp,切换到用户异常栈。
- 在栈上压入一个UTrapframe结构。
- 将eip设置为curenv->env_pgfault_upcall,然后回到用户态执行curenv->env_pgfault_upcall处的代码。
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
-
原文
您现在需要更改 kern/trap.c 中的页面错误处理代码,以便按照以下方式处理来自用户模式的页面错误。我们将用户环境在发生错误时的状态称为“陷阱时状态”(trap-time state)。
如果没有注册页面错误处理程序,JOS 内核像以前一样销毁用户环境,并显示一条消息。否则,内核在异常栈上设置一个陷阱帧(trap frame),该陷阱帧看起来像 inc/trap.h 中的 struct UTrapframe:
<-- UXSTACKTOP 陷阱时 esp 陷阱时 eflags 陷阱时 eip 陷阱时 eax struct PushRegs 的开始 陷阱时 ecx 陷阱时 edx 陷阱时 ebx 陷阱时 esp 陷阱时 ebp 陷阱时 esi 陷阱时 edi struct PushRegs 的结束 tf_err (错误代码) fault_va <-- 处理程序运行时的 %esp
然后内核安排用户环境恢复执行,页面错误处理程序在异常栈上运行,使用这个栈帧;您必须弄清楚如何实现这一点。fault_va 是引起页面错误的虚拟地址。
如果用户环境在异常发生时已经在用户异常栈上运行,则页面错误处理程序本身已经出错。在这种情况下,您应该在当前的 tf->tf_esp 下面而不是在 UXSTACKTOP 开始新的栈帧。您应该先推送一个空的 32 位字,然后是一个 struct UTrapframe。
要测试 tf->tf_esp 是否已经在用户异常栈上,检查它是否在 UXSTACKTOP-PGSIZE 和 UXSTACKTOP-1(包括)之间的范围内。
Exercise 9.
在 kern/trap.c 中实现 page_fault_handler 中的代码,以便将页面错误分派到用户模式处理程序。在向异常栈写入数据时,请确保采取适当的预防措施。(如果用户环境的异常栈空间用完了会发生什么?)
void page_fault_handler(struct Trapframe *tf) { uint32_t fault_va; // 从处理器的 CR2 寄存器读取引起错误的地址 fault_va = rcr2(); // 处理内核模式下的页面错误。 // LAB 3: Your code here. // 如果错误发生在内核模式(代码段寄存器的低两位为0),则触发 panic if ((tf->tf_cs & 3) == 0) panic("page_fault_handler():page fault in kernel mode!\\n"); // LAB 4: Your code here. // 如果当前环境注册了页面错误处理函数 if (curenv->env_pgfault_upcall) { uintptr_t stacktop = UXSTACKTOP; // 设置异常栈顶 // 如果已经在异常栈上,则更新栈顶地址 if (UXSTACKTOP - PGSIZE < tf->tf_esp && tf->tf_esp < UXSTACKTOP) { stacktop = tf->tf_esp; } // 计算 UTrapframe 结构加上一个 32 位空间的大小 uint32_t size = sizeof(struct UTrapframe) + sizeof(uint32_t); // 检查异常栈上的空间是否有效 user_mem_assert(curenv, (void *)stacktop - size, size, PTE_U | PTE_W); // 在异常栈上创建 UTrapframe 结构 struct UTrapframe *utr = (struct UTrapframe *)(stacktop - size); utr->utf_fault_va = fault_va; // 设置引起错误的虚拟地址 utr->utf_err = tf->tf_err; // 设置错误代码 utr->utf_regs = tf->tf_regs; // 保存寄存器状态 utr->utf_eip = tf->tf_eip; // 保存指令指针 utr->utf_eflags = tf->tf_eflags; // 保存标志寄存器 utr->utf_esp = tf->tf_esp; // 保存栈指针 // 设置要执行的页面错误处理程序地址和栈顶 curenv->env_tf.tf_eip = (uintptr_t)curenv->env_pgfault_upcall; curenv->env_tf.tf_esp = (uintptr_t)utr; env_run(curenv); // 重新运行用户环境,此时会进入页面错误处理程序 } // 销毁引起错误的用户环境 cprintf("[%08x] user fault va %08x ip %08x\\n", curenv->env_id, fault_va, tf->tf_eip); print_trapframe(tf); env_destroy(curenv); }
这个函数主要处理用户模式下发生的页面错误。如果当前环境注册了页面错误处理程序,它会在用户的异常栈上设置一个
UTrapframe
结构体,并重新运行用户环境,进入页面错误处理程序。如果没有注册页面错误处理程序,或者在处理页面错误时遇到问题,它会销毁引起错误的用户环境。-
cpu中 CR2 寄存器是用来干什么的
在 x86 架构的 CPU 中,CR2 寄存器(控制寄存器 2)用于存储最近发生的页面错误的线性地址。当 CPU 检测到页面错误(Page Fault)时,它会自动将引起错误的虚拟地址存储在 CR2 寄存器中。这个功能对操作系统的错误处理机制特别重要,尤其是在实现虚拟内存管理时。
简而言之,CR2 寄存器的主要用途如下:
- 存储页面错误地址:当发生页面错误时(例如,当进程尝试访问未映射的内存或违反内存访问权限时),CR2 寄存器保存了导致错误的虚拟内存地址。
- 错误处理和调试:操作系统的页面错误处理程序(Page Fault Handler)会读取 CR2 寄存器的值,以确定引发错误的具体内存地址。这有助于操作系统决定如何应对错误,例如,通过映射缺失的内存页面或向应用程序发送信号。
在现代操作系统中,对 CR2 寄存器的使用是内核页面错误处理流程的一个关键部分。
User-mode Page Fault Entrypoint
接下来,您需要实现一个汇编例程,该例程将负责调用 C 语言编写的页面错误处理程序,并在原始导致错误的指令处恢复执行。这个汇编例程是将使用 sys_env_set_pgfault_upcall() 注册到内核的处理程序。
Exercise 10.
在 lib/pfentry.S 中实现
_pgfault_upcall
例程。有趣的部分是返回到用户代码中导致页面错误的原始点。你将直接返回那里,而不是通过内核返回。难点在于同时切换栈并重新加载 EIP(扩展指令指针)。_pgfault_upcall: // Call the C page fault handler. pushl %esp // function argument: pointer to UTF movl _pgfault_handler, %eax call *%eax //调用页处理函数 addl $4, %esp // pop function argument // LAB 4: Your code here. // Restore the trap-time registers. After you do this, you // can no longer modify any general-purpose registers. // LAB 4: Your code here. addl $8, %esp //跳过utf_fault_va和utf_err movl 40(%esp), %eax //保存中断发生时的esp到eax movl 32(%esp), %ecx //保存终端发生时的eip到ecx movl %ecx, -4(%eax) //将中断发生时的esp值亚入到到原来的栈中 popal 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. // 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
-
解释
这段程序是一个汇编例程,名为
_pgfault_upcall
,用于处理 JOS 操作系统中的页面错误(page faults)。当用户模式的代码触发页面错误时,这个例程被调用。它的主要任务是调用 C 语言编写的页面错误处理函数,恢复导致页面错误时的寄存器状态,并将控制权返回到导致错误的原始指令。下面是对这段程序的逐行解释:-
调用 C 语言的页面错误处理程序:
pushl %esp // 将指向 UTrapframe 的指针作为参数压栈 movl _pgfault_handler, %eax // 将 C 语言页面错误处理函数的地址加载到 eax call *%eax // 调用页面错误处理函数 addl $4, %esp // 清理栈上的参数 //在 x86 架构中,栈是向下增长的数据结构,意味着数据被推入栈时栈指针(%esp)会减小,弹出时则增大。每个栈项通常是 4 字节(32 位)宽。addl $4, %esp 这个操作实际上是在调整栈指针,以跳过栈顶的一个 32 位(4 字节)的元素。
-
恢复陷阱时的寄存器状态:
addl $8, %esp // 跳过 UTrapframe 中的 fault_va 和 err 字段 movl 40(%esp), %eax // 将陷阱时的 esp 寄存器值加载到 eax movl 32(%esp), %ecx // 将陷阱时的 eip 寄存器值加载到 ecx movl %ecx, -4(%eax) // 将陷阱时的 eip 值推入原来的用户栈中 popal // 恢复其他通用寄存器的值 addl $4, %esp // 跳过原始栈中的 eip
-
恢复 eflags 寄存器:
popfl // 从栈中恢复 eflags 寄存器的值
-
切换回陷阱时的栈:
popl %esp // 从栈中恢复 esp 寄存器的值,切换回陷阱时的栈
-
返回至导致页面错误的指令重新执行:
lea -4(%esp), %esp // 调整栈指针,以指向错误发生时的 eip ret // 返回,以重新执行导致页面错误的那条指令
总体来说,这个例程是实现用户级页面错误处理的关键部分,它确保在处理完页面错误后,用户程序可以继续从发生错误的地方执行。
-
最后,您需要实现用户级页面错误处理机制的 C 语言用户库部分。
Exercise 11. Finish
set_pgfault_handler()
in lib/pgfault.c.void set_pgfault_handler(void (*handler)(struct UTrapframe *utf)) { int r; if (_pgfault_handler == 0) { // First time through! // LAB 4: Your code here. int r = sys_page_alloc(0, (void *)(UXSTACKTOP-PGSIZE), PTE_W | PTE_U | PTE_P); //为当前进程分配异常栈 if (r < 0) { panic("set_pgfault_handler:sys_page_alloc failed");; } sys_env_set_pgfault_upcall(0, _pgfault_upcall); //系统调用,设置进程的env_pgfault_upcall属性 } // Save handler pointer for assembly to call. _pgfault_handler = handler; }
-
总结
- 引发缺页中断,执行内核函数链:trap()->trap_dispatch()->page_fault_handler()
- page_fault_handler()切换栈到用户异常栈,并且压入UTrapframe结构,然后调用curenv->env_pgfault_upcall(系统调用sys_env_set_pgfault_upcall()设置)处代码。又重新回到用户态。
- 进入_pgfault_upcall处的代码执行,调用_pgfault_handler(库函数set_pgfault_handler()设置)处的代码,最后返回到缺页中断发生时的那条指令重新执行。
Implementing Copy-on-Write Fork
到目前已经可以实现用户级别的写时拷贝fork函数了。fork流程如下:
- 使用set_pgfault_handler()设置缺页处理函数。
- 调用sys_exofork()系统调用,在内核中创建一个Env结构,复制当前用户环境寄存器状态,UTOP以下的页目录还没有建立,新创建的进程还不能直接运行。
- 拷贝父进程的页表和页目录到子进程。对于可写的页,将对应的PTE的PTE_COW位设置为1。
- 为子进程设置_pgfault_upcall。
- 将子进程状态设置为ENV_RUNNABLE。
缺页处理函数pgfault()流程如下:
- 如果发现错误是因为写造成的(错误码是FEC_WR)并且该页的PTE_COW是1,则进行执行第2步,否则直接panic。
- 分配一个新的物理页,并将之前出现错误的页的内容拷贝到新的物理页,然后重新映射线性地址到新的物理页。
-
Exercise 12. Implement
fork
,duppage
andpgfault
in lib/fork.c.Test your code with the forktree program. It should produce the following messages, with interspersed 'new env', 'free env', and 'exiting gracefully' messages. The messages may not appear in this order, and the environment IDs may be different.
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'
pgfault
static void
pgfault(struct UTrapframe *utf)
{
void *addr = (void *) utf->utf_fault_va; // 引起页面错误的虚拟地址
uint32_t err = utf->utf_err; // 页面错误的错误代码
int r;
// 检查导致错误的访问是否是对一个写时复制页面的写操作。如果不是,则触发 panic。
if (!((err & FEC_WR) && (uvpt[PGNUM(addr)] & PTE_COW))) {
panic("pgfault():not cow");
}
// 分配一个新页面,并在临时位置(PFTEMP)映射,
// 然后将旧页面的数据复制到新页面,最后将新页面映射到旧页面的地址。
addr = ROUNDDOWN(addr, PGSIZE); // 将地址向下舍入到页面边界
if ((r = sys_page_map(0, addr, 0, PFTEMP, PTE_U|PTE_P)) < 0)
panic("sys_page_map: %e", r); // 将当前进程的 PFTEMP 映射到 addr 指向的物理页
if ((r = sys_page_alloc(0, addr, PTE_P|PTE_U|PTE_W)) < 0)
panic("sys_page_alloc: %e", r); // 分配新的物理页并映射到 addr
memmove(addr, PFTEMP, PGSIZE); // 将数据从 PFTEMP 拷贝到新页面
if ((r = sys_page_unmap(0, PFTEMP)) < 0)
panic("sys_page_unmap: %e", r); // 解除 PFTEMP 的映射
}
作用
这个函数在发生写时复制页面错误时被调用。它首先检查页面错误是否是由于写入到写时复制页面引起的。如果是,它会执行以下步骤来处理这个错误:
- 分配新页面:为发生错误的地址分配一个新的物理页面,并将其映射到一个临时地址
PFTEMP
。 - 复制数据:将原来页面的数据复制到新分配的页面。
- 更新映射:更新发生页面错误的虚拟地址,使其指向新分配的物理页面,而不是原来的写时复制页面。
- 清理:解除临时地址
PFTEMP
的映射。
通过这种方式,JOS 实现了写时复制机制,允许多个进程共享同一物理内存页面,只有在其中一个进程尝试写入页面时,才会创建一个新的物理页面副本。这是一种有效的内存管理策略,可以节省内存空间并减少不必要的数据复制。
duppage
static int
duppage(envid_t envid, unsigned pn)
{
int r;
// 计算虚拟地址
void *addr = (void*) (pn * PGSIZE);
// 对于标记为 PTE_SHARE 的页,拷贝映射关系,同时两个进程都保持对该页的读写权限
if (uvpt[pn] & PTE_SHARE) {
sys_page_map(0, addr, envid, addr, PTE_SYSCALL);
}
// 对于写时复制(COW)的或者可写的页,将映射关系复制到子进程,
// 并将当前进程和子进程的页表项都标记为 PTE_COW
else if ((uvpt[pn] & PTE_W) || (uvpt[pn] & PTE_COW)) {
if ((r = sys_page_map(0, addr, envid, addr, PTE_COW|PTE_U|PTE_P)) < 0)
panic("sys_page_map:%e", r);
if ((r = sys_page_map(0, addr, 0, addr, PTE_COW|PTE_U|PTE_P)) < 0)
panic("sys_page_map:%e", r);
}
// 对于只读页,仅需复制映射关系
else {
sys_page_map(0, addr, envid, addr, PTE_U|PTE_P);
}
return 0;
}
作用
这个函数在进程分叉(fork)操作中使用。它的主要作用是:
- 共享页面:如果页面被标记为共享(
PTE_SHARE
),则它会保持原有的访问权限,并在父进程和子进程之间共享。 - 写时复制页面和可写页面:如果页面是可写的或已经被标记为写时复制,则它会在父子进程间建立写时复制的映射。这意味着,只要页面不被修改,父子进程可以共享相同的物理页面。一旦任一进程尝试写入,页面会被复制,从而保证了内存隔离。
- 只读页面:对于只读页面,函数仅复制映射关系,无需进一步处理。
duppage
函数是实现 JOS 中高效内存管理和进程分叉的关键部分,尤其是在处理写时复制和页面共享方面。
fork
envid_t
fork(void)
{
// 设置页面错误处理函数
set_pgfault_handler(pgfault);
// 创建一个新环境,复制当前环境的寄存器状态。
// 注意,此时新环境的 UTOP 以下的页表尚未建立。
envid_t envid = sys_exofork();
if (envid == 0) {
// 子进程将执行这部分代码
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}
if (envid < 0) {
panic("sys_exofork: %e", envid);
}
// 遍历当前环境的地址空间
uint32_t addr;
for (addr = 0; addr < USTACKTOP; addr += PGSIZE) {
// 检查每一页是否存在并被用户进程使用
if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P)
&& (uvpt[PGNUM(addr)] & PTE_U)) {
// 复制页面映射关系到子进程
duppage(envid, PGNUM(addr));
}
}
int r;
// 为子进程分配异常栈
if ((r = sys_page_alloc(envid, (void *)(UXSTACKTOP-PGSIZE), PTE_P | PTE_W | PTE_U)) < 0)
panic("sys_page_alloc: %e", r);
// 为子进程设置页面错误处理回调函数
sys_env_set_pgfault_upcall(envid, _pgfault_upcall);
// 设置子进程状态为可运行(RUNNABLE)
if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0)
panic("sys_env_set_status: %e", r);
return envid; // 返回子进程的环境ID
}
作用
这个 fork
函数用于在 JOS 中创建一个新的进程:
- 首先,它设置了当前环境的页面错误处理函数。
- 使用
sys_exofork
系统调用创建一个新环境(子进程),这个调用只复制寄存器状态,不复制内存。 - 然后,它遍历当前环境(父进程)的地址空间,并使用
duppage
函数复制页面映射关系到新环境(子进程)。这包括处理写时复制页面和共享页面。 - 为子进程分配一个异常栈。
- 设置子进程的页面错误处理回调函数为
_pgfault_upcall
。 - 最后,将子进程的状态设置为可运行(RUNNABLE)。
通过这种方式,fork
实现了进程的创建和内存空间的复制,允许子进程独立于父进程运行,同时共享一部分内存资源。
-
sys_exofork()
sys_exofork()
是 JOS 操作系统中的一个系统调用,它用于创建一个新的进程(在 JOS 中称为“环境”)。这个函数的主要作用是生成一个新的环境,该环境是调用环境的一个副本,但有几个重要的区别:- 寄存器状态的复制:
sys_exofork()
复制调用环境(即父环境)的寄存器状态到新环境(即子环境)。这意味着子环境在被调度运行时将从父环境离开的同一点开始执行。 - 不共享物理内存:尽管新环境的寄存器状态被复制,但它并没有获得父环境的物理内存副本。换句话说,新环境开始时没有分配任何物理内存页面。内存映射(包括共享内存和写时复制页面)需要通过其他机制在父子环境间设置。
- 返回值:在父环境中,
sys_exofork()
返回新创建的子环境的环境ID。在子环境中,它返回 0。这类似于 Unix 中的fork()
函数。
sys_exofork()
的这种设计是出于效率和灵活性考虑。它允许父环境控制子环境的内存映射方式,可以实现写时复制(Copy-On-Write, COW)等高效内存管理策略。此外,由于新环境开始时没有自己的物理内存,这减少了fork()
操作的开销,使得进程创建更加高效。 - 寄存器状态的复制:
-
解释
这段代码是 JOS 操作系统中
fork
函数的一部分,用于在父进程和子进程之间复制页面映射。下面是这段代码的详细解释:-
变量初始化:
uint32_t addr;
这里定义了一个
uint32_t
类型的变量addr
,用于遍历进程的虚拟地址空间。 -
遍历地址空间:
for (addr = 0; addr < USTACKTOP; addr += PGSIZE) {
这个循环遍历了进程的地址空间,从地址 0 开始,一直到
USTACKTOP
(用户栈顶的地址)。PGSIZE
是页面大小,通常为 4KB。每次循环增加addr
的值,以页面为单位逐个检查。 -
检查页面状态:
if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_U)) {
这里进行了几项检查:
uvpd[PDX(addr)] & PTE_P
:检查页面目录项(PDX(addr) 计算出页面目录索引)是否存在。PTE_P
是页面存在的标志。uvpt[PGNUM(addr)] & PTE_P
:使用uvpt
数组(一个用户空间可见的页面表数组)来检查addr
所在的页面是否存在。uvpt[PGNUM(addr)] & PTE_U
:检查页面是否是用户模式可访问的。PTE_U
是用户模式可访问标志。- 在 JOS 操作系统中,
PGNUM
是一个宏,用于计算给定虚拟地址所在的页面号。页面号是虚拟地址除以页面大小的结果,它提供了一种方式来索引或引用特定的虚拟内存页面。
-
复制页面映射:
duppage(envid, PGNUM(addr));
如果以上条件都满足,意味着当前页面是有效的、存在的,并且是用户模式可访问的。此时,调用
duppage
函数复制这个页面的映射关系到子进程。duppage
函数负责处理页面是普通页面、写时复制页面或共享页面的情况。
简而言之,这段代码的目的是遍历父进程的整个用户空间,对于每个有效且用户模式可访问的页面,使用
duppage
函数将其映射关系复制到子进程。这是 JOS 中fork
实现的关键部分,它允许子进程共享父进程的内存,同时实现写时复制(COW)等高效内存管理策略。 -
Part C: Preemptive Multitasking and Inter-Process communication (IPC)
Clock Interrupts and Preemption
运行用户程序 user/spin
。这个测试程序创建一个子环境,一旦获得 CPU 控制权,它就会简单地在一个紧密循环中无限期地旋转。父环境和内核都无法重新获得 CPU。显然,这对于保护系统免受用户模式环境中的错误或恶意代码的影响来说并不理想,因为任何用户模式环境只需进入一个无限循环并且不再释放 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。例如,时钟中断是 IRQ 0。因此,IDT[IRQ_OFFSET+0](即 IDT[32])包含内核中时钟的中断处理程序的地址。选择这个 IRQ_OFFSET 是为了确保设备中断不与处理器异常重叠,这显然会引起混淆。(事实上,在 PC 运行 MS-DOS 的早期,IRQ_OFFSET 实际上是零,这确实引起了处理硬件中断和处理器异常之间的大量混淆!)
在 JOS 中,我们相比于 xv6 Unix 做了一个关键简化。在内核中始终禁用外部设备中断(并且像 xv6 一样,在用户空间时启用)。外部中断由 %eflags 寄存器的 FL_IF 标志位控制(见 inc/mmu.h)。当此位被设置时,外部中断被启用。虽然这个位可以通过几种方式修改,但由于我们的简化,我们将仅通过保存和恢复 %eflags 寄存器的过程在进入和离开用户模式时处理它。
-
解释
这段话解释了 JOS 操作系统中如何处理外部中断(设备中断),特别是与 IRQs(中断请求)和 IDT(中断描述符表)相关的细节。以下是该段话的详细解释:
- IRQs 和 IDT:
- 外部中断(即设备中断)被称为 IRQs。有 16 个可能的 IRQ,编号为 0 到 15。
- 这些 IRQs 需要映射到 IDT 的特定条目。在 JOS 中,
pic_init
函数负责将 IRQs 0-15 映射到 IDT 条目 IRQ_OFFSET 到 IRQ_OFFSET+15。
- IRQ_OFFSET:
- 在 JOS 的
inc/trap.h
文件中,定义了 IRQ_OFFSET 为十进制 32。 - 因此,IDT 条目 32-47 对应于 IRQs 0-15。例如,时钟中断(IRQ 0)对应的 IDT 条目是 32。
- 在 JOS 的
- 中断和异常的区分:
- 选择 IRQ_OFFSET 为 32 是为了确保设备中断不与处理器异常重叠,从而避免混淆。
- 在早期 PC 和 MS-DOS 系统中,IRQ_OFFSET 实际上是零,导致了硬件中断和处理器异常之间的混淆。
- 中断的启用和禁用:
- 在 JOS 中,与 xv6 Unix 相比,做了一个简化:在内核中始终禁用外部设备中断,并在用户空间时启用它们。
- 外部中断由
%eflags
寄存器的 FL_IF 标志位控制。当此位被设置时,外部中断被启用。 %eflags
寄存器的 FL_IF 标志位可以通过多种方式修改,但在 JOS 中,只通过在进入和离开用户模式时保存和恢复%eflags
寄存器来处理它。
- 用户环境的中断处理:
- 为了处理用户模式下的中断,需要确保用户环境在运行时设置了 FL_IF 标志,这样当中断到达时,它就会被处理器接收并由中断处理代码处理。
- 如果 FL_IF 标志未被设置,则中断会被屏蔽或忽略,直到中断被重新启用。
- JOS 的引导程序在最开始屏蔽了中断,而至今还没有重新启用它们。
简而言之,这段描述涉及了 JOS 中如何设置和处理外部中断,包括 IRQ 到 IDT 的映射、中断与异常的区别、以及如何在内核和用户模式之间切换中断的启用状态。
- IRQs 和 IDT:
你将必须确保用户环境在运行时设置 FL_IF 标志,这样当中断到达时,它就会传递给处理器并由你的中断代码处理。否则,中断被屏蔽,或忽略直到中断再次被启用。我们在引导程序的第一条指令中屏蔽了中断,到目前为止我们还没有重新启用它们。
Exercise 13.
练习 13:修改
kern/trapentry.S
和kern/trap.c
来初始化 IDT(中断描述符表)中适当的条目,并为 IRQs 0 到 15 提供处理程序。然后修改kern/env.c
中的env_alloc()
函数的代码,以确保用户环境总是在启用中断的状态下运行。同时取消注释
sched_halt()
中的sti
指令,以便空闲的 CPU 取消屏蔽中断。当处理器调用硬件中断处理程序时,它从不推送错误代码。你可能需要此时重新阅读 80386 参考手册的第 9.2 节,或 IA-32 Intel 架构软件开发者手册第 3 卷的第 5.8 节。
完成这个练习后,如果你运行你的内核并使用任何运行时间较长的测试程序(例如
spin
),你应该会看到内核打印硬件中断的陷阱帧。虽然现在处理器中已经启用了中断,但 JOS 还没有处理它们,所以你应该会看到它错误地将每个中断归咎于当前运行的用户环境并销毁它。最终,它应该会耗尽环境并进入监视器。
- 修改
Trapentry.s
,当调用硬件中断处理时,处理器不会传入错误代码,因此我们需要调用TRAPHANDLER_NOEC
宏。
// IRQs
TRAPHANDLER_NOEC(timer_handler, IRQ_OFFSET + IRQ_TIMER);
TRAPHANDLER_NOEC(kbd_handler, IRQ_OFFSET + IRQ_KBD);
TRAPHANDLER_NOEC(serial_handler, IRQ_OFFSET + IRQ_SERIAL);
TRAPHANDLER_NOEC(spurious_handler, IRQ_OFFSET + IRQ_SPURIOUS);
TRAPHANDLER_NOEC(ide_handler, IRQ_OFFSET + IRQ_IDE);
TRAPHANDLER_NOEC(error_handler, IRQ_OFFSET + IRQ_ERROR);
- 修改
trap.c
, 注册IDT。
void dblflt_handler();
void timer_handler();
void kbd_handler();
void serial_handler();
void spurious_handler();
void ide_handler();
void error_handler();
...
// IRQ
SETGATE(idt[IRQ_OFFSET + IRQ_TIMER], 0, GD_KT, timer_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_KBD], 0, GD_KT, kbd_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_SERIAL], 0, GD_KT, serial_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_SPURIOUS], 0, GD_KT, spurious_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_IDE], 0, GD_KT, ide_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_ERROR], 0, GD_KT, error_handler, 3);
- IDT表项中的每一项都初始化为中断门,这样在发生任何中断/异常的时候,陷入内核态的时候,CPU都会将%eflags寄存器上的FL_IF标志位清0,关闭中断;切换回用户态的时候,CPU将内核栈中保存的%eflags寄存器弹回%eflags寄存器,恢复原来的状态。You will have to ensure that the FL_IF flag is set in user environments when they run so that when an interrupt arrives, it gets passed through to the processor and handled by your interrupt code。在
env_allco
中加入以下代码, 同时取消sched_halt()
中sti
的注释,使能中断。
// Enable interrupts while in user mode.
// LAB 4: Your code here.
e->env_tf.tf_eflags |= FL_IF;
Handling Clock Interrupts
在 user/spin
程序中,一旦子环境开始运行,它就会陷入一个循环中,内核永远无法重新获得控制权。我们需要对硬件进行编程,定期产生时钟中断,这将强制控制权返回到内核,在那里我们可以切换控制权到不同的用户环境。
我们为你编写的 lapic_init
和 pic_init
函数调用(来自 init.c
中的 i386_init
),已经设置了时钟和中断控制器以生成中断。现在你需要编写代码来处理这些中断。
Exercise 14.
练习 14:修改内核的
trap_dispatch()
函数,使其在时钟中断发生时调用sched_yield()
函数,以找到并运行不同的用户环境。完成这项修改后,你应该能够让
user/spin
测试程序正常工作:父环境应该创建子环境,然后通过sys_yield()
函数几次让出 CPU 给子环境,但在每个时间片后重新获得 CPU 控制权,最后杀死子环境并优雅地终止。
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
lapic_eoi();
sched_yield();
return;
}
-
解释
这段代码是 JOS 操作系统中
trap_dispatch()
函数的一部分,用于处理时钟中断。下面是对这段代码的详细解释:if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) { lapic_eoi(); sched_yield(); return; }
-
检查时钟中断:
if (tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER) {
这里检查陷阱帧(
tf
)中的陷阱号(tf_trapno
)是否等于时钟中断的编号。IRQ_OFFSET
是中断请求(IRQ)在中断描述符表(IDT)中的偏移量,而IRQ_TIMER
是时钟中断的 IRQ 编号。所以,IRQ_OFFSET + IRQ_TIMER
是时钟中断在 IDT 中的索引。 -
发送中断结束信号(End of Interrupt, EOI):
lapic_eoi();
lapic_eoi()
调用发送一个 EOI 信号给本地 APIC(高级可编程中断控制器),表明时钟中断已经被处理。这是必需的,因为在处理完中断后,需要通知 APIC 中断处理已完成,以便它可以发送更多中断。 -
调用调度器:
sched_yield();
sched_yield()
调用是调度器的一部分,它选择另一个用户环境来运行。如果有多个可运行的环境,它会在它们之间轮转,从而实现时间片轮转调度。 -
返回:
return;
中断处理完成后,通过返回离开中断处理函数。
总之,这段代码的作用是在时钟中断发生时,确保中断被正确地标记为已处理,并调用调度器选择下一个要运行的用户环境。这是实现抢占式多任务处理的关键部分,允许内核在用户模式环境中有效地分享 CPU 时间。
-
Inter-Process communication (IPC)
(技术上在 JOS 中,这被称为“环境间通信”或“IEC”,但其他人都称之为 IPC,所以我们将使用这个标准术语。)
我们一直在关注操作系统的隔离方面,即它提供的每个程序都拥有一台机器的假象。操作系统的另一个重要服务是允许程序在需要时相互通信。让程序与其他程序交互可以非常强大。Unix 管道模型是一个典型的例子。
有许多模型用于进程间通信。即使在今天,关于哪种模型最好仍有争论。我们不会参与这场辩论。相反,我们将实现一个简单的 IPC 机制,然后尝试使用它。
IPC in JOS
你将实现一些额外的 JOS 内核系统调用,它们共同提供一个简单的进程间通信(IPC)机制。你将实现两个系统调用,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 系统调用被精心设计为“安全”的:一个环境不能仅通过向另一个环境发送消息就导致其发生故障(除非目标环境本身也存在漏洞)。
为了尝试发送一个值,一个环境会调用 sys_ipc_try_send
,同时提供接收方的环境 ID 和要发送的值。如果命名的环境实际上正在接收(它已经调用了 sys_ipc_recv
并且还没有获取到值),那么发送将传递该消息并返回 0。否则,发送将返回 -E_IPC_NOT_RECV
,以指示目标环境当前没有期望接收值。
用户空间的库函数 ipc_recv
将负责调用 sys_ipc_recv
,然后在当前环境的 struct Env
中查找关于接收到的值的信息。
类似地,库函数 ipc_send
将负责重复调用 sys_ipc_try_send
,直到发送成功。
sys_ipc_recv()和sys_ipc_try_send()是这么协作的:
- 当某个进程调用sys_ipc_recv()后,该进程会阻塞(状态被置为ENV_NOT_RUNNABLE),直到另一个进程向它发送“消息”。当进程调用sys_ipc_recv()传入dstva参数时,表明当前进程准备接收页映射。
- 进程可以调用sys_ipc_try_send()向指定的进程发送“消息”,如果目标进程已经调用了sys_ipc_recv(),那么就发送数据,然后返回0,否则返回-E_IPC_NOT_RECV,表示目标进程不希望接受数据。当传入srcva参数时,表明发送进程希望和接收进程共享srcva对应的物理页。如果发送成功了发送进程的srcva和接收进程的dstva将指向相同的物理页。
Transferring Pages
当一个环境(进程)使用有效的 dstva
参数(低于 UTOP)调用 sys_ipc_recv
时,该环境表明它愿意接收页面映射。如果发送者发送了一个页面,那么这个页面应该在接收者的地址空间的 dstva
处被映射。如果接收者在 dstva
已经有一个页面映射了,那么之前的页面将被取消映射。
当一个环境使用有效的 srcva
(低于 UTOP)调用 sys_ipc_try_send
时,这意味着发送者想要将当前映射在 srcva
处的页面发送给接收者,权限为 perm
。在成功的 IPC(进程间通信)之后,发送者在其地址空间中保留了 srcva
处页面的原始映射,但接收者也在接收者的地址空间中获得了该物理页面在最初由接收者指定的 dstva
处的映射。因此,这个页面在发送者和接收者之间变成了共享的。
如果发送者或接收者都没有指示应该传输页面,那么不会传输任何页面。在任何 IPC 之后,内核都会在接收者的 Env 结构中设置新字段 env_ipc_perm
,表示接收到的页面的权限,如果没有接收到页面,则为零。
Implementing IPC
Exercise 15.
实现sys_ipc_recv()和sys_ipc_try_send()。包装函数ipc_recv()和 ipc_send()。
练习 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
很有趣,以了解幕后发生的所有分叉和 IPC 操作。
sys_ipc_try_send
static int
sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm) {
struct Env *rcvenv;
int ret = envid2env(envid, &rcvenv, 0);//将环境 ID(Environment ID)转换为对应的 Env 结构体指针。
if (ret) return ret;
// 检查接收环境是否准备好接收消息
if (!rcvenv->env_ipc_recving) return -E_IPC_NOT_RECV;
// 如果 srcva 低于 UTOP,则发送方试图发送一个页面
if (srcva < (void*)UTOP) {
pte_t *pte;
struct PageInfo *pg = page_lookup(curenv->env_pgdir, srcva, &pte);//查找一个给定虚拟地址对应的物理页面,并获取该页面的页面表项(Page Table Entry, PTE)
// 检查 srcva 是否页对齐
if (srcva != ROUNDDOWN(srcva, PGSIZE)) {
return -E_INVAL;
}
// 检查权限是否正确,perm 应该是 pte 权限的子集
if ((*pte & perm & 7) != (perm & 7)) {
return -E_INVAL;
}
// 检查 srcva 是否已经映射到物理页
if (!pg) {
return -E_INVAL;
}
// 如果要求写权限,检查页面是否真的可写
if ((perm & PTE_W) && !(*pte & PTE_W)) {
return -E_INVAL;
}
// 如果接收环境准备接收页面,将页面插入到接收环境的地址空间
if (rcvenv->env_ipc_dstva < (void*)UTOP) {
ret = page_insert(rcvenv->env_pgdir, pg, rcvenv->env_ipc_dstva, perm);
//rcvenv->env_pgdir: 接收环境的页面目录地址。
//pg: 要映射的物理页面的 PageInfo 结构体指针。
//rcvenv->env_ipc_dstva: 接收环境中的目标虚拟地址,即将映射到的地址。
//perm: 页面映射的权限标志,如可读(PTE_U)、可写(PTE_W)等
if (ret) return ret;
rcvenv->env_ipc_perm = perm;
}
}
// 设置接收环境的相关 IPC 字段
rcvenv->env_ipc_recving = 0; // 标记接收环境可再次接收消息
rcvenv->env_ipc_from = curenv->env_id; // 记录发送方的环境 ID
rcvenv->env_ipc_value = value; // 设置传递的值
rcvenv->env_status = ENV_RUNNABLE; // 将接收环境标记为可运行
rcvenv->env_tf.tf_regs.reg_eax = 0; // 将接收环境的返回值设置为 0
return 0;
}
作用
sys_ipc_try_send
用于进程间通信,允许一个进程(发送方)向另一个进程(接收方)发送一个 32 位值和(可选的)一个页面映射。函数首先检查接收方是否准备好接收消息,然后根据提供的地址 srcva
和权限 perm
检查发送方希望传递的页面映射是否有效。如果一切正常,函数会将页面映射到接收方的地址空间,并更新接收方环境的 IPC 相关字段,如发送方 ID、传递的值以及页面权限。最后,函数将接收方标记为可运行,以便调度器可以再次运行它。
sys_ipc_recv
static int
sys_ipc_recv(void *dstva) {
// 检查 dstva 是否有效。dstva 应该低于 UTOP(用户空间的顶部)且页对齐。
if (dstva < (void *)UTOP && dstva != ROUNDDOWN(dstva, PGSIZE)) {
return -E_INVAL;
}
// 设置当前环境为接收状态
curenv->env_ipc_recving = 1;
// 将当前环境设置为不可运行状态
curenv->env_status = ENV_NOT_RUNNABLE;
// 记录预期接收页面映射的虚拟地址
curenv->env_ipc_dstva = dstva;
// 让出 CPU,等待接收消息
sys_yield();
return 0;
}
作用
sys_ipc_recv
函数允许一个进程(环境)表明它准备好接收来自其他进程的 IPC 消息。- 函数首先检查给定的目标虚拟地址
dstva
是否有效。有效的地址应低于用户空间顶部UTOP
且必须是页对齐的。如果地址无效,函数返回错误码E_INVAL
。 - 如果
dstva
有效,函数将当前环境设置为接收状态(env_ipc_recving = 1
),并标记当前环境为不可运行(ENV_NOT_RUNNABLE
)。这意味着环境将停止运行,直到接收到 IPC 消息。 - 然后记录预期接收页面映射的虚拟地址
dstva
。如果发送方决定通过 IPC 传递一个页面映射,这个地址将被用来映射该页面。 - 最后,调用
sys_yield
让出 CPU,这使得内核调度器可以运行其他环境,当前环境将等待直到它接收到一个 IPC 消息。
这个机制是 JOS 中 IPC 实现的一部分,允许环境在需要时等待并接收来自其他环境的消息。
ipc_recv
int32_t
ipc_recv(envid_t *from_env_store, void *pg, int *perm_store) {
// 如果 pg 为空,则将其设置为 -1,表示不预期接收任何页面
if (pg == NULL) {
pg = (void *)-1;
}
// 调用 sys_ipc_recv 系统调用来接收消息
int r = sys_ipc_recv(pg);
if (r < 0) { // 系统调用失败
// 如果提供了存储发送方环境 ID 的地址,设置为 0
if (from_env_store) *from_env_store = 0;
// 如果提供了存储页面权限的地址,设置为 0
if (perm_store) *perm_store = 0;
return r;
}
// 存储发送方的环境 ID
if (from_env_store)
*from_env_store = thisenv->env_ipc_from;
// 存储接收到的页面权限
if (perm_store)
*perm_store = thisenv->env_ipc_perm;
// 返回接收到的 32 位值
return thisenv->env_ipc_value;
}
作用
ipc_recv
函数允许一个进程接收来自另一个进程的 IPC 消息。- 函数接受三个参数:用于存储发送方环境 ID 的
from_env_store
,预期接收页面映射的虚拟地址pg
,以及用于存储页面权限的perm_store
。 - 如果
pg
为空,函数将其解释为不预期接收任何页面,而是使用1
作为特殊值。 - 调用
sys_ipc_recv
系统调用接收消息。如果调用失败,函数将from_env_store
和perm_store
(如果它们非空)设置为 0,并返回错误码。 - 如果系统调用成功,函数将从当前环境的 IPC 状态中提取发送方的环境 ID、接收到的页面权限和消息值,并将它们存储在提供的地址中(如果非空)。
- 最后,函数返回接收到的 32 位值。
ipc_recv
函数是 JOS IPC 机制的一部分,它简化了接收方进程在接收 IPC 消息时的流程,并处理了消息接收的不同情况。通过这种方式,JOS 支持进程间的有效通信。
ipc_send
void
ipc_send(envid_t to_env, uint32_t val, void *pg, int perm) {
// 如果 pg 为空,则将其设置为 -1,表示不传递任何页面
if (pg == NULL) {
pg = (void *)-1;
}
int r;
while(1) {
// 尝试发送消息
r = sys_ipc_try_send(to_env, val, pg, perm);
if (r == 0) { // 发送成功
return;
} else if (r == -E_IPC_NOT_RECV) { // 接收进程未准备好接收
// 让出 CPU,等待接收进程准备好
sys_yield();
} else { // 其它错误
panic("ipc_send():%e", r);
}
}
}
作用
ipc_send
函数允许一个进程向另一个指定的进程发送一个 32 位的数值val
,以及(可选的)一个页面pg
,附带权限perm
。- 如果
pg
参数为NULL
,函数将其解释为不发送任何页面,而是使用1
作为特殊值。 - 函数进入一个循环,不断尝试通过
sys_ipc_try_send
系统调用发送消息。 - 如果
sys_ipc_try_send
返回 0,表示消息发送成功,函数返回。 - 如果返回
E_IPC_NOT_RECV
,表示目标进程尚未准备好接收消息。在这种情况下,ipc_send
通过调用sys_yield
让出 CPU,给其他进程运行的机会,特别是允许目标进程运行并准备接收消息。 - 如果发生其他错误,函数调用
panic
,这通常表示遇到了严重的问题。
ipc_send
函数是 JOS IPC 机制的一部分,它简化了发送方进程在发送 IPC 消息时的流程,并处理了消息发送的不同情况。通过这种方式,JOS 支持进程间的有效通信。
总结
本lab还是围绕进程这个概念来展开的。主要介绍了四部分:
-
支持多处理器。现代处理器一般都是多核的,这样每个CPU能同时运行不同进程,实现并行。需要用锁解决多CPU的竞争。 CPU和进程在内核中的数据结构如下图所示:
-
实现进程调度算法。 一种是非抢占式式的,另一种是抢占式的,借助时钟中断实现,时钟中断到来时,内核调用sched_yield()选择另一个Env结构执行。
-
实现写时拷贝fork(进程创建)。fork()是库函数,会调用sys_exofork(void)这个系统调用,该系统调用在内核中为子进程创建一个新的Env结构,然将父进程的寄存器状态复制给该Env结构,复制页表,对于PTE_W为1的页目录,复制的同时,设置PTE_COW标志。为父进程和子进程设置缺页处理函数,处理逻辑:当缺页中断发生是因为写写时拷贝的地址,分配一个新的物理页,然后将该虚拟地址映射到新的物理页。原理总结如下:
- 实现进程间通信。本质还是进入内核修改Env结构的的页映射关系。原理总结如下: