实验完善代码 LAB2-4下载链接 提取码:79t8
0、下载lab4 的源代码
[root@xxx lab]# git add .
[root@xxx lab]# git commit -am 'changes to lab3 after handin'
[lab3 76d9a7d] changes to lab3 after handin
12 files changed, 457 insertions(+), 104 deletions(-)
[root@xxx lab]# git pull
Already up-to-date.
[root@xxx lab]# git checkout -b lab4 origin/lab4
Branch lab4 set up to track remote branch lab4 from origin.
Switched to a new branch 'lab4'
[root@xxx lab]# git merge lab3
1、实验目标
在LAB3 进程和中断管理的基础上,本实现的目标是在JOS 操作系统中实现多进程管理和进程间消息通信的功能。
在LAB3 中,我们知道,进程是一个执行中的程序实例。利用分时技术,操作系统上同时可以运行多个进程。分时技术的基本原理是把CPU 的运行时间分成一个个规定长度的时间片(实验中一个时间片为100ms),让每个进程在一个时间片内运行。当进程的时间片用完时,系统就利用调度程序切换到另外一个进程去运行。当一个进程在执行时,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换至另一个进程时候,它需要保存当前进程的所有状态,即保存当前进程上下文,以便在再次执行该进程时,能恢复到切换时的状态继续执行下去。
在操作系统的进程调度方式中,有抢占式和非抢占式,本实验中采用抢占式进程调度,即现在进程在运行过程中,如果有重要或紧迫的进程到达(其状态必须为就绪态),则现运行进程将被迫放弃处理机,系统将处理机立刻分配给新到达的进程,其需要时钟中断处理程序实现。其中如何产生多个进程以及如何进行进程切换是本实验的目标。
LAB4 可以分为三部分:实现调度算法、创建新的进程环境和进程间通信。第一部分通过循环轮询调度算法实现多用户进程;第二部分通过实现类似于unix 进程创建的fork() 函数创建新的进程以及实现用户态下的缺页错误处理函数;最后通过时钟中断实现用户进程间的消息通信等。
2、Part A :多处理器和多任务
在实验的第一部分,将扩展JOS 系统使其运行在多处理器状态下,然后实现一些新的系统调用允许在用户环境下 新建进程。这里将使用轮询的方式实现进程间的调度算法。
(1)多处理器
我们将使JOS 系统支持对称多处理器,对称多处理器即所有处理器的地位是相同的,所有的资源(特别是存储器、中断已经I/O空间),都具有相同的可访问性,消除了结构上的障碍。当所有的处理器地位都是相同的时候, 在boot 阶段他们可以分为两类: 引导处理器(BSP: the bootstrap processor)主要负责将内核可执行程序加载到物理内存,并对系统进行初始化;应用处理器(AP:Application Processor)仅仅在系统启动并开始运行之后被引导处理器(BSP)激活。至于哪一个处理器是启动处理器,这取决于硬件和BIOS ,到目前为止,我们的程序都是运行在引导处理器上(BSP)。
在一个对称多处理器系统中,每个CPU有一个自己的局部高级可编程中断控制器(LAPIC),负责将中断传递至指定的处理器。APIC 也为其所属的CPU 提供唯一的标识符。在这部分实验中,我们将利用LAPIC 的以下基本功能:
- 读取局部高级可编程中断控制器(LAPIC)==的标识符(APIC ID) 区分代码现在运行在哪个CPU上(函数cpunum());
- 从引导处理器(BSP)发送STARTUP 进程间中断(IPI)到应用处理器(APs)启动应用处理器(函数 lapic_startup());
- 在第三部分,我们将编写LAPIC 内置的时钟去触发时钟中断以实现抢占式多任务。
每个处理器的LAPIC 使用 内存映射IO(MMIO),在MMIO 中,物理内存的一部分硬链接到一些I/O设备的寄存器,因此用于访问内存的加载/存储指令也能被用于访问设备寄存器。在LAB1 我们已经接触过VGA(显示缓冲区),位于物理内存的0X0A0000。LAPIC 位于物理内存的0xFE000000,这部分位于KERNBASE 的上面,超出了前面实验所述的可映射范围。JOS 系统的虚拟地址映射在MMIOBASE 预留了4MB 使得有LAPIC 地方可以被映射,就像前面实验那样。接下来的实验会引入MMIO ,因此需要先写一个简单的函数为MMIO分配空间,并将IO设备映射到此空间内。
- Exercise 1
编写 函数 mmio_map_region 为MMIO分配空间,并将IO设备映射到此空间内。函数的功能: - 将物理内存pa 起的 size 大小映射到虚拟内存 MMIOBASE;
- size 需要是物理页面的整倍数;
- 用户IO 映射,访问的物理地址非正常的DRAM,因此需要禁用cache ,权限为 PTE_PCD|PTE_PWT |PTE_W。
void * mmio_map_region(physaddr_t pa, size_t size)
{
static uintptr_t base = MMIOBASE;
size = ROUNDUP(size,PGSIZE); //将size 处理为PGSIZE 的倍数
if(base + size >= MMIOLIM)
panic("overflow of MMIOLIM memory \n");
boot_map_region(kern_pgdir, base, size, pa, PTE_PCD|PTE_PWT|PTE_W);
uintptr_t retsult = base;
base += size;
return (void *)result;
//panic("mmio_map_region not implemented");
}
(2)应用处理器(AP)的启动程序
在启动应用处理器之前,启动处理器(BSP)应该先被告知处理器的总个数、他们的APIC IDs 和LAPIC 的MMIO 地址。 kern/mpconfig.c中的函数mp_init() 就是实现这些内容的,通过读取在BOIS 内存中的MP 设置表。
kern/init.c中的 boot_aps() 函数驱动AP 启动程序。 APs 启动的时候为实模式, boot_aps() 函数复制 AP 的入口代码到内存中一个实模式下可寻址的地址。不像bootloader ,我们可以控制在哪里开始执行AP,复制入口代码到地址0x7000(MPENTRY_PADDR),或者任何位于低640KB 的没有使用的物理地址都可以。
接下来 boot_aps() 一个接一个地通过BSP(启动处理器)发送STARTUP进程间中断(IPI) 到应用处理器的LAPIC 单元激活应用处理器,该过程依赖于应用处理器开始运行的初始的CS:IP 地址(也就是MPENTRY_PADDR)。入口代码 kern/mpentry.S(应用处理器的入口代码) 和 boot/boot.S(启动处理器的入口代码)中的代码很相似。进行简单的初始化设置之后,AP 分页模式被启动,处于保护模式,然后接下来调用kern/init.c 中的mp_main() 函数。boot_aps() 函数在启动下一个AP之前等待现在AP 发送CPU_STARTED 信号。
- Exercise 2
为 MPENTRY_PADDR 预留空间,避免被分配出去。
void page_init(void)
{
size_t i;
page_free_list = NULL;
int aps = MPENTRY_PADDR/ PGSIZE;
int num_alloc = ((uint32_t)boot_alloc(0) - KERNBASE) / PGSIZE;
int num_iohole = 96;
for (i = 0; i < npages; i++) {
if( i==0 )
pages[i].pp_ref = 1;
else if(i == aps)
pages[i].pp_ref = 1; //这里可以和下面的合并,但是为了区分,我分开写的
else if(i >= npages_basemem && i < npages_basemem + num_iohole + num_alloc)
pages[i].pp_ref = 1;
else {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
}
}
程序 make qemu 的结果如图所示,现在check_kern_pgdir() 报错了,没关系,后面会修复。
- Question
比较 kern/mpentry.S 和 boot/boot.S,记住kern/mpentry.S 是被编译并链接运行在KERNBASE 上的,宏定义 MPBOOTPHYS 的含义是什么?为什么这个宏定义在 kern/mpentry.S 中很重要, 但是在 boot/boot.S 并不重要?(如果在kern/mpentry.S 没有这个宏定义会发生什么? )
这个宏定义MPBOOTPHYS 可以用来计算AP “boot” 阶段 符号的绝对地址,因为mpentry.S 是运行在 BSP ,汇编代码中的符号的地址代表的虚拟地址(链接地址),符号的的真实地址可以通过这个宏定义计算出。因为boot/boot.S 中 处理器刚开始处于实模式状态,符号的地址就是真实的物理地址。如果在 mpentry.S中没有这个宏定义,那么AP 将无法找到其代码(加载地址)所在处。
(3)每个处理器的状态以及初始化
多处理器操作系统的编写中,区分每个 CPU 的私有状态和全局状态(整个系统共享)是非常重要的。kern/cpu.h 定义了CPU 的私有状态,并定义了结构体 CpuInfo 来保存每个CPU 的状态。cpunum()函数返回 对应CPU 的ID ,这个ID 可以被用来作为索引。宏定义 thiscpu 记录当前运行CPU 的CpuInfo。下面是几个需要注意的CPU 私有状态:
-
内核堆栈
因为多处理器可能会同时陷入内核,我们需要为每一个处理器分配一个堆栈,以防止他们中间相互干扰。数组 percpu_kstacks[NCPU][KSTKSIZE] 为NCPU 的内核堆栈保留空间。
在LAB 2 ,我们将BSP(启动处理器)的堆栈映射到 bootstack 处(位于KSTACKTOP下面),同样的,在这个实验中,我们将每个CPU 的内核堆栈映射到物理内存中,CPU0 的堆栈沿着KSTACKTOP 往下生长,CPU 1 的堆栈将在CPU0 堆栈下KSTKGAP 位置处(注意这里预留了一个间隔)。inc/memlayout.h 显示了内存分区的具体细节。 -
TSS 和 TSS 描述符
每个CPU 的TSS(任务状态段)可以区分每个CPU 的堆栈位于哪里。对于CPU i 其TSS 被被存在 cpus[i].cpu_ts 中,相应的TSS 描述符定义在 GDT 入口 gdt[(GD_TSS0 >> 3) + i] 。 kern/trap.c 中定义的全局变量ts 将不再有用。 -
当前运行环境指针
竟然每个处理器可以同时运行用户程序,我们重新定义 curenv 为 cpus[cpunum()].cpu_env (or thiscpu->cpu_env) ,这样可以区分是哪个CPU 的当前运行环境。 -
系统寄存器
所有的寄存器,包括系统寄存器,是每个CPU 私有的。因此初始化寄存器的指令应该在每个CPU 初始化的时候都要进行一次。函数env_init_percpu() 和 trap_init_percpu() 进行相应的处理。 -
Exercise 3 和 Exercise 4
编写mem_init_mp 将处理器内核堆栈映射到实际的物理空间,并编写trap_init_percpu 初始化各个处理器的TSS 描述符。
//
static void mem_init_mp(void)
{
uintptr_t kstacktop_i = KSTACKTOP;
for(int i = 0;i < NCPU;++i)
{
kstacktop_i = KSTACKTOP - i * (KSTKSIZE + KSTKGAP);
boot_map_region(kern_pgdir,kstacktop_i-KSTKSIZE,KSTKSIZE,PADDR(percpu_kstacks[i]),PTE_W);
}
}
make qemu
结果
void trap_init_percpu(void)
{
int i = cpunum();
uintptr_t kstacktop_i = KSTACKTOP - i * (KSTKSIZE + KSTKGAP);
cpus[i].cpu_ts.ts_esp0 = kstacktop_i;
cpus[i].cpu_ts.ts_ss0 = GD_KD;
cpus[i].cpu_ts.ts_iomb = sizeof(struct Taskstate);
gdt[(GD_TSS0 >> 3)+i] = SEG16(STS_T32A, (uint32_t) (&cpus[i].cpu_ts),sizeof(struct Taskstate) - 1, 0);
//两种方法都可以
/*int i = thiscpu->cpu_id;
thiscpu->cpu_ts.ts_esp0 = kstacktop_i;
thiscpu->cpu_ts.ts_ss0 = GD_KD;
thiscpu->cpu_ts.ts_iomb = sizeof(struct Taskstate);
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;
ltr(GD_TSS0 +(i << 3));
// Load the IDT
lidt(&idt_pd);
}
问题:为什么是 (GD_TSS0 >> 3)?并且gdt 表中能看到设置其他 GDT 段描述符的时候也偏移了三位。
回答:因为观察全局段描述符的定义,可以发现后三位都是预留的,前面饿的实验分析过,这里预留是为了特权级别的设置(0,1,2,3),在利用gdt 表的索引 填入gdt 表格时候,索引是从0 开始的连续的数,因此将后面三位表示特权级别的先移出去,然后填入表格。
对于每个CPU 而言,唯一不同的是 TSS 描述符,因为这个描述符指向其各自的内核栈。TSS 描述符在gdt 的最后项,因此根据其id 顺序累加写入gdt 表即可(gdt[(GD_TSS0>>3)+id])。之后载入 tss 的时候,需要用指令 ltr,载入的地址为 (gdt[(GD_TSS0>>3)+id])<< 3 = GD_TSS0+(id<<3)。
// Global descriptor numbers
#define GD_KT 0x08 // kernel text
#define GD_KD 0x10 // kernel data
#define GD_UT 0x18 // user text
#define GD_UD 0x20 // user data
#define GD_TSS0 0x28 // Task segment selector for CPU 0
make qemu CPUS=4
的结果
(4)锁
我们现在的代码在初始化AP之后自旋在函数 mp_main() 中。在让AP 继续运行之前,我们需要先解决当多个处理器同时运行时候的race condition 。最简单的方法是用一个大的内核锁,这是一个全局锁,哪个处理器进入了内核态,就锁起内核资源,避免其它的处理器使用,当获得锁的处理器完成任务之后释放锁,并切换到用户态。在这种模式中,用户态下多个处理器可以同时运行,内核态下只有一个处理器可以运行。如果已经有其他的处理去处于内核态,任何其他想要切换到内核态的处理器都被阻塞。
文件 kern/spinlock.h 中声明了一个全局锁变量kernel_lock,同时提供了 lock_kernel() 来获取锁 和 unlock_kernel() 函数来释放锁。注意这里锁的实现采用的是自旋锁,不是互斥锁(互斥锁通过使等待锁的进程阻塞;自旋锁是在获得锁之前一直在原地打转,不断测试锁的状态)。kernel_lock应该被用在下面四个函数中:
- i386_init(),在BSP(启动处理器) 唤醒其他APs (应用处理器)的时候需要获得全局锁,因为BSP 启动和初始化 AP 以及其创建、启动自己的环境都是在内核态发生的,这之间对内核的访问权都应该属于BSP,在BSP 进入到用户态、释放锁之前,所有的AP 都在等待锁,在BSP 释放锁之后,只有一个AP 能获得锁并且创建、运行其自己的进程,然后释放锁;其他AP等待上一个AP 释放锁;
- mp_main() ,在初始化APs 之后需要获取锁,然后调用函数 sched_yield() 启动运行在APs 的应用程序;
- trap() ,当从用户态切换到内核态的时候需要获取锁。至于中断/异常是被用户进程触发还是内核触发可以通过检查 tf_cs 的低位;
- env_run() ; 一旦从内核态切换为用户态,需要释放锁。注意这个时间点,不能太早也不能太晚,否则可能会出现 race condition 或者死锁。(这里解锁在函数)
/env_run()
lcr3(PADDR(curenv->env_pgdir));
unlock_kernel();
env_pop_tf(&curenv->env_tf); //切换到用户程序,上下文切换,从curenv 到 e
- Exercise 5
这个按照上面所说的四个函数依次加锁或者解锁即可。
这里重新理解下 从 BSP 启动 和 初始化 APs 的过程,以及为什么需要加锁。首先,BSP 在 boot_aps() 中启动其他的应用处理器,分为两个主要的步骤:拷贝mentry.S 的 代码到物理内存 MPENTRY_PADDR,循环启动每个 AP,这个循环的过程主要如下所示:
for CPU in CPUs:
从 mentry.S 启动 AP ,进入 mp_main 初始化 AP
1、内存映射IO,使能正确访问LAPI 局部高级可编程中断控制器
2、初始化GDT
3、中断初始化
4、设置CPU_STATUS
5、原地自旋
while CPU.STATUS != CPU_STARTED
(5)轮询调度
接下俩的任务是实现多处理器轮询调度,提醒一下,本实验采用的是对称多处理器结构,也就是每个CPU 的地位相同。轮询调度原理如下:
- kern/sched.c 中的 sched_yield() 函数负责选择一个新的进程来运行。上一个进程运行结束之后,CPU 从 envs 数组中顺序遍历并选择下一个可运行的进程(状态为ENV_RUNNABLE),然后调用 env_run() 来与逆行进程;
- sched_yield() 函数要 注意同一个进程不能同时在两个CPU 中运行, 可以通过进程的状态标志来确定;
- 此处提供了一个新的系统调用函数 sys_yield() ,用户进程能调用这个函数来唤醒内核函数 sched_yield(),同时主动让出 CPU 资源给其他进程。
- Exercise 6
- (1)完善进程调度函数 sched_yield()
void sched_yield(void)
{
struct Env *idle;
// LAB 4: Your code here.
if (curenv == NULL){
idle = &envs[0];}
else{
idle = curenv + 1;}
int i ;
for (i=0; i<NENV; i++) {
if (idle->env_status == ENV_RUNNABLE) {
// env_run不会返回
env_run(idle);
}
if (++idle > &envs[NENV-1]){
idle = &envs[0];}
}
if (curenv && curenv->env_status == ENV_RUNNING){
//cprintf("sched_yield---ENV_RUNNING \n");
env_run(curenv);}
sched_halt();
}
}
- (2)完善sys_call 函数
case(SYS_yield):
sys_yield();
break;
- (3)运行结果
(6)进程创建系统调用(fork)
现在JOS 系统已经可以运行进程并实现用户级进程调度了,但是这里可以运行的进程必须是由内核创建的。接下来我们将实现在用户环境下创建新的进程。
Unix 提供fork() 系统调用来创建新进程。fork() 将父进程的整个地址空间复制到子进程中。从用户空间来看。父进程和子进程之间唯一不同的是ID 。每个进程都有自己私有的地址空间,其他进程无法访问。
接下来的实验,将在JOS 系统实现一个类似fork 函数 来新建进程。我们将编写:
- 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,0表示当前进程。进程ID 和 进程 用 kern/env.c 文件中的函数envid2env() 来转换。
在 user/dumbfork.c 文件中提供了一个很原始的类似fork() 系统调用的函数。这个文件中调用了上述的函数在父进程的地址空间上创建和运行一个子进程。这两个进程之间的切换用函数 sys_yield 来实现。
- Exercise 7
- sys_exofork() 函数
static envid_t sys_exofork(void)
{
struct Env * e = NULL;
int err = env_alloc(&e ,curenv->env_id);
if(err < 0)
{
return err;
}
e->env_status = ENV_NOT_RUNNABLE;
e->env_tf = curenv->env_tf;
e->env_tf.tf_regs.reg_eax = 0;
return e->env_id;
//panic("sys_exofork not implemented");
}
- sys_env_set_status() 函数
static int sys_env_set_status(envid_t envid, int status)
{
struct Env * env = NULL;
if(envid2env(envid,&env,1) < 0)
return -E_BAD_ENV;
if((status != ENV_RUNNABLE) || (status != ENV_NOT_RUNNABLE))
return -E_INVAL;
env->env_status = status;
return 0;
//panic("sys_env_set_status not implemented");
}
- sys_page_alloc() 函数
static int
sys_page_alloc(envid_t envid, void *va, int perm)
{
if((uintptr_t)va >= UTOP || (uintptr_t)va & 0xFFF)
return -E_INVAL;
if((perm & (PTE_U | PTE_P)) == 0 )
return -E_INVAL;
if((perm & ~(PTE_U | PTE_P | PTE_AVAIL | PTE_W)) != 0)
return -E_INVAL;
struct Env * env = NULL;
if(envid2env(envid,&env,1) < 0)
return -E_BAD_ENV;
struct PageInfo * pp = page_alloc(ALLOC_ZERO);
if(pp == NULL)
return -E_NO_MEM;
if(page_insert(env->env_pgdir,pp,va,perm) < 0)
{
page_free(pp);
return -E_NO_MEM;
}
memset(page2kva(pp),0,PGSIZE);//free memory for child
return 0;
// panic("sys_page_alloc not implemented");
}
- sys_page_map() 函数
static int sys_page_map(envid_t srcenvid, void *srcva,
envid_t dstenvid, void *dstva, int perm)
{
struct Env * srcenv = NULL;
if(envid2env(srcenvid,&srcenv,1) < 0)
return -E_BAD_ENV;
struct Env * dstenv = NULL;
if(envid2env(dstenvid,&dstenv,1) < 0)
return -E_BAD_ENV;
if(((uintptr_t)srcva >= UTOP) || ((uintptr_t)srcva & 0xFFF) || ((uintptr_t)dstva >= UTOP) || ((uintptr_t)dstva & 0xFFF) )
return -E_INVAL;
if((perm & (PTE_U | PTE_P)) == 0 )
return -E_INVAL;
if((perm & ~(PTE_U | PTE_P | PTE_AVAIL | PTE_W)) != 0)
return -E_INVAL;
pte_t *pt_entry = NULL;
struct PageInfo * pp = page_lookup(srcenv->env_pgdir, srcva, &pt_entry);
if(pp == NULL || ((perm & PTE_W) > 0 && (*pt_entry & PTE_W) == 0))
return -E_INVAL;
if(page_insert(dstenv->env_pgdir,pp, dstva,perm) < 0)
return -E_NO_MEM;
return 0;
//panic("sys_page_map not implemented");
}
- sys_page_unmap() 函数
static int sys_page_unmap(envid_t envid, void *va)
{
struct Env * env = NULL;
if(envid2env(envid,&env,1) < 0)
return -E_BAD_ENV;
if((uintptr_t)va >= UTOP || (uintptr_t)va & 0xFFF)
return -E_INVAL;
page_remove(env->env_pgdir,va);
return 0;
//panic("sys_page_unmap not implemented");
}
- i386_init() 函数
#if defined(TEST)
// Don't touch -- used by grading script!
ENV_CREATE(TEST, ENV_TYPE_USER);
#else
//ENV_CREATE(user_primes, ENV_TYPE_USER);
//ENV_CREATE(user_idle, ENV_TYPE_USER); //守护进程
//ENV_CREATE(user_yield, ENV_TYPE_USER);
//ENV_CREATE(user_yield, ENV_TYPE_USER);
//ENV_CREATE(user_yield, ENV_TYPE_USER);
ENV_CREATE(user_dumbfork, ENV_TYPE_USER);
#endif // TEST*
make qemu
结果
make grade
结果
自此,part A 全部完成!