You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

674 lines
27 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

## 操作系统原理课程设计
> 扩充proxy kernel的代码使你的内核可以支持以下应用程序
## 实验一 多进程支持 ##
### 应用: ###
App5_1的代码如下
int main(){
int a=10;
int ret=-1;
printf("print proc app5_1\n");
if((ret=fork()) == 0) {
printf("print proc this is child process;my pid = %d\n",getpid());
a=a-1;
printf("print proc a=%d\n",a);
}else {
int wait_ret = -1;
wait_ret=wait(ret);
a=a-2;
printf("print proc this is farther process;my pid = %d\n",getpid());
printf("print proc a=%d\n",a);
}
if((ret=fork()) == 0) {
a=a-3;
printf("print proc this is child process;my pid = %d\n",getpid());
printf("print proc a=%d\n",a);
}else {
a=a-4;
int wait_ret = -1;
wait_ret=wait(ret);
printf("print proc this is farther process;my pid = %d\n",getpid());
printf("print proc a=%d\n",a);
}
return 0;
}
### 实验一任务: ###
#### 任务一 : proc_pagetable/ vmcopy ####
实验任务描述:
实现pk/proc.c中的proc_pagetable与vmcopy函数为进程创建用户页表并映射用户内存确保你的代码仍可以保证app5的正确运行。
实验预期输出:
$ spike obj/pke app/elf/app5
得到输出:
PKE IS RUNNING
to host 10
from host 0
elf name app/elf/app5
sched class: RR_scheduler
++ setup timer interrupts
log: proc init
father process
this is father process;my pid = 1
this is child process;my pid = 2
#### 任务二do_wait ####
实验任务描述:
实现`do_wait`函数支持app5_1.c的运行
$ spike obj/pke app/elf/app5_1
预期得到输出:
PKE IS RUNNING
to host 10
from host 0
elf name app/elf/app5_1
sched class: RR_scheduler
++ setup timer interrupts
log: proc init
print proc app5_1
print proc this is child process;my pid = 2
print proc a=9
print proc this is child process;my pid = 3
print proc a=6
print proc this is farther process;my pid = 2
print proc a=5
print proc this is farther process;my pid = 1
print proc a=8
print proc this is child process;my pid = 4
print proc a=5
print proc this is farther process;my pid = 1
此时运行测试脚本:
$ python3 ./pke-final-1
预期得到输出:
build pk : OK
running app5_1 : OK
test fork : OK
Score: 20/20
### 实验一提示: ###
app5_1.c是一段多进程的代码我们可以结合下图进行分析</br>
<div style="text-align: center; width: 1000px; ">
<img src="lab_figures/final/app5_1.png" alt="app5_1" style="margin: 0 auto;" />
</div>
首先对app的内容进行分析程序首先在父进程father1中调用了fork()产生子进程child1父进程father1进入等待状态。而子进程child1此时打印出a=9随机再次调用fork(),产生子进程child4child4打印输出6后退出此时child1结束等待输出5随即father1被唤醒输出8。Father1进行第二次fork产生child3。Child3输出5后返回父进程father1再次被唤醒最后输出4。故而正确的打印顺序为9、6、5、8、5、4。</br>
现在我们要支持上述app就需要完善pke的进程支持。</br>
在实验五的代码中,函数`do_fork()`中我们需要实现函数`copy_mm()`,即复制虚拟内存。该函数的本质实际上是对页表进程操作。由于实验五中只需要实现父子进程的切换,所以我们可以直接复制内核页表作为进程页表,并且再复制后的内核页表上为每个进程映射其用户地址空间。</br>
<div style="text-align: center; width: 1000px; ">
<img src="lab_figures/final/mem_map.png" alt="app5_1" style="margin: 0 auto;" />
</div>
上图为pke的虚实映射关系。其中内核空间采用对等映射即令物理地址等于虚拟地址这样做使我们在内核下操作虚拟地址时无需再进行地址转换例如我们使用`__page_alloc`分配了一段物理内存并且要使用memset函数设置这段内存的值时我们可以直接将分配得到的物理地址传入memset函数注意此时memset中这个地址会被当作虚拟地址。结合实验三的知识我们知道这里必然会经由页表进行地址转化但由于内核页表中物理地址与虚拟地址采用了对等的映射我们实际上得到了和这个虚拟地址相同的物理地址从而完成了写的操作。<br/>
虚拟地址空间中用户地址空间从零开始到USER STACK TOP结束RISCV中物理地址从0x80000000开始这一部分用户地址自然不存在对等映射的物理地址空间。如图所示它映射至first_free_paddr到Top Memory之间的内存。<br/>
我们知道每个用户进程都有它独立的用户代码,而所有用户都共享内核代码。所以,一种更为清晰的设计如下,每个用户进程维护一张属于该进程的用户页表,所有进程共享内核页表。我们为进程结构体`proc_struct`添加upagetable属性用以维护用户页表其结构如下
struct proc_struct {
list_entry_t run_link;
struct run_queue *rq;
int time_slice;
enum proc_state state; // Process state
int pid; // Process ID
int runs; // the running times of Proces
int exit_code;
uintptr_t kstack; // Process kernel stack
volatile bool need_resched; // bool value: need to be rescheduled to release CPU?
struct proc_struct *parent; // the parent process
struct context context; // Switch here to run process
trapframe_t *tf; // Trap frame for current interrupt
++ uintptr_t upagetable; // the base addr of Page Directroy Table
uint32_t flags; // Process flag
char name[PROC_NAME_LEN + 1]; // Process name
list_entry_t list_link; // Process link list
uint32_t wait_state; // waiting state
struct proc_struct *cptr, *yptr, *optr; // relations between processes
list_entry_t hash_link; // Process hash list
};
这样我们拥有了一张属于每个进程的用户页表现在我们来看如何对其进行分配与维护。首先是进程间共性的部分我们知道在用户态发生异常时代码会跳转到stvec控制状态寄存器所指向的位置继续进行所以所有用户页表中都需要对这部分代码进行映射。
pke中stvec指向`trap_entry`,我们可以对其内容进行简要的分析:<br/>
.global trap_entry
trap_entry:
#将sp与sscratch中的值互换
csrrw sp, sscratch, sp
bnez sp, write_stap
csrr sp, sscratch
addi sp,sp,-320
save_tf
jal 1f
write_stap:
addi sp,sp,-320
save_tf
进入`trap_entry`后首先交换sp和sscratch寄存器此时分为两个情况第一是由内核空间跳转至`trap_entry`,第二种则是由用户空间跳转至`trap_entry`。</br>
在pk.c中的`boot_loader`函数中sscratch于内核态被设置为0。而在proc.c的forkret函数中forkret函数模拟上次调用是由用户态进入内核态的假象将ssctatch写为内核栈的栈帧。综上可以得出结论从用户态进入`trap_entry`后sscratch值为该进程内核栈顶trapframe的指针而从内核态进入trapframe后sscratch的值为0。<br/>
所以在交换sscratch与sp后代码对sp即原sscratch的值的值进行判断如果sp中的值为0表示其从内核态进入trap代码顺序向下执行后跳转1f。如果不是0则表示其从用户态进入trap代码跳转write_satp。<br/>
我们先看sp为0即内核态进入的情况首先sp作为栈指针寄存器自然是不能为0的我们需要将sscratch的值即原sp的值再次写回sp寄存器。此时sp指向内核栈接着调用宏save_tf将当前trap的trapframe保存在该内核栈中。 <br/>
接着是sp为1即用户态进入的情况。它直接使用当前sp即原sscratch中的值作为内核栈地址注意由于forkret在进入用户态时向sscratch中写入了即将进入用户态运行的进程的内核栈此时我们从用户态进入内核态从sscratch中得到内核栈的自然也同运行的进程所一致。<br/>
接着,无论是哪种进入内核的状态都为欲存储的栈帧分配了栈空间,且都调用了宏`save_tf`</br>
.macro save_tf
# save gprs
STORE x1,1*REGBYTES(x2)
STORE x3,3*REGBYTES(x2)
STORE x4,4*REGBYTES(x2)
STORE x5,5*REGBYTES(x2)
STORE x6,6*REGBYTES(x2)
STORE x7,7*REGBYTES(x2)
STORE x8,8*REGBYTES(x2)
STORE x9,9*REGBYTES(x2)
STORE x10,10*REGBYTES(x2)
STORE x11,11*REGBYTES(x2)
STORE x12,12*REGBYTES(x2)
STORE x13,13*REGBYTES(x2)
STORE x14,14*REGBYTES(x2)
STORE x15,15*REGBYTES(x2)
STORE x16,16*REGBYTES(x2)
STORE x17,17*REGBYTES(x2)
STORE x18,18*REGBYTES(x2)
STORE x19,19*REGBYTES(x2)
STORE x20,20*REGBYTES(x2)
STORE x21,21*REGBYTES(x2)
STORE x22,22*REGBYTES(x2)
STORE x23,23*REGBYTES(x2)
STORE x24,24*REGBYTES(x2)
STORE x25,25*REGBYTES(x2)
STORE x26,26*REGBYTES(x2)
STORE x27,27*REGBYTES(x2)
STORE x28,28*REGBYTES(x2)
STORE x29,29*REGBYTES(x2)
STORE x30,30*REGBYTES(x2)
STORE x31,31*REGBYTES(x2)
csrrw t0,sscratch,x0
csrr s0,sstatus
csrr t1,sepc
csrr t2,sbadaddr
csrr t3,scause
STORE t0,2*REGBYTES(x2)
STORE s0,32*REGBYTES(x2)
STORE t1,33*REGBYTES(x2)
STORE t2,34*REGBYTES(x2)
STORE t3,35*REGBYTES(x2)
# get faulting insn, if it wasn't a fetch-related trap
li x5,-1
STORE x5,36*REGBYTES(x2)
简单来说它用来保存栈帧。X2即sp寄存器此时存储的是进程内核栈的指针。故而save_tf在预留出的栈帧中首先存储了除sp以外的31个通用寄存器接着将sscratch中的值即用户栈指针存入t0、sstatus存入s0、sepc存入t1、sbadaddr存入t2、scause存入t3并保存。注意由于csrrw 将x0写入sscratchsscratch置位为0。
接下来由用户态进入的trap需要将在用户态时使用的用户页表更换为内核态的内核页表
write_stap:
addi sp,sp,-320
save_tf
move a0,sp
ld t1, 37*REGBYTES(a0)
csrw satp, t1
sfence.vma zero, zero
这里内核页表被加载入t1中然后将t1写入satp寄存器并刷新当前CPU的TLB。在RISCV中架构下每个CPU都会将页表条目缓冲在转译后备缓冲区Translation Lookaside Buffer当页表更改时必须告诉CPU使缓存的TLB条目无效。如果没有这样做那么在以后的某个时间TLB可能依旧会使用旧的缓存映射这将可能导致某个进程在某些页面上乱写其他进程的内存。RISC-V提供指令sfence.vma刷新当前CPU的TLB。故而每次更换页表时调用sfence.vma指令是必要的。
最后将sp作为参数传递给`handle_trap`,进入中断处理:</br>
move a0,sp
j al handle_trap
同样当代码从forkrets返回时则执行了相反的逻辑
forkrets:
andi s0,s0,SSTATUS_SPP
bnez s0,start_user
move sp, a0
csrw sptbr, a1
sfence.vma zero, zero
addi sp,sp,320
csrw sscratch,sp
j start_user
这里我们首先对sstatus寄存器进行介绍sstatus作为状态控制寄存器其中的SIE位控制是否允许设备中断。如果内核清除了SIE则RISC-V将推迟设备中断直到内核设置SIE。SPP位指示tarp是来自用户模式还是主管模式并且sret将返回该种模式。当异常发生时硬件将自动将sstatus的SIE位置零以禁用中断并且将发生异常之前的特权模式保存在SPP中。而当调用sret时机器会将SPIE的值写入SIE来恢复异常发生之前的中断使能情况并且按照SPP中的值恢复特权模式。</br>
故而在forkrets中代码先判断了`SSTATUS_SPP`的值是否为零若其为零则表示异常发生之前是处于用户态若不为零则表示异常发生之前是处于内核态。对于用户态我们重新将用户页表写入satp寄存器并且将内核栈地址写入sscratch以备下一次异常发生时使用。对于内核态则直接进入start_user</br>
.globl start_user
start_user:
LOAD t0, 32*REGBYTES(a0)
LOAD t1, 33*REGBYTES(a0)
csrw sstatus, t0
csrw sepc, t1
# restore x registers
LOAD x1,1*REGBYTES(a0)
LOAD x2,2*REGBYTES(a0)
LOAD x3,3*REGBYTES(a0)
LOAD x4,4*REGBYTES(a0)
LOAD x5,5*REGBYTES(a0)
LOAD x6,6*REGBYTES(a0)
LOAD x7,7*REGBYTES(a0)
LOAD x8,8*REGBYTES(a0)
LOAD x9,9*REGBYTES(a0)
LOAD x11,11*REGBYTES(a0)
LOAD x12,12*REGBYTES(a0)
LOAD x13,13*REGBYTES(a0)
LOAD x14,14*REGBYTES(a0)
LOAD x15,15*REGBYTES(a0)
LOAD x16,16*REGBYTES(a0)
LOAD x17,17*REGBYTES(a0)
LOAD x18,18*REGBYTES(a0)
LOAD x19,19*REGBYTES(a0)
LOAD x20,20*REGBYTES(a0)
LOAD x21,21*REGBYTES(a0)
LOAD x22,22*REGBYTES(a0)
LOAD x23,23*REGBYTES(a0)
LOAD x24,24*REGBYTES(a0)
LOAD x25,25*REGBYTES(a0)
LOAD x26,26*REGBYTES(a0)
LOAD x27,27*REGBYTES(a0)
LOAD x28,28*REGBYTES(a0)
LOAD x29,29*REGBYTES(a0)
LOAD x30,30*REGBYTES(a0)
LOAD x31,31*REGBYTES(a0)
# restore a0 last
LOAD x10,10*REGBYTES(a0)
# gtfo
sret
start_user中首先恢复sstatus以及sepc然后加载32个通用寄存器并调用sret返回。
综上,为了用户态的代码能够跳转进入中断处理程序,上述由用户态进入内核态的代码需要在所有进程的用户页表中进行映射。</br>
同样每个用户页表中都应该维护自己的用户堆栈需要对虚拟地址current.stack_top-RISCV_PGSIZE进行映射。</br>
以上功能均由`proc_pagetable`函数实现。</br>
现在,我们已经完成了用户页表的创建,不过目前页表中除了我们映射的异常入口与用户栈还是一片空白,接下来我们需要对用户内存进程映射。</br>
还记得fork函数中的`copy_mm`吗?我们需要在该函数中将父进程的用户内存拷贝给子进程,或者说复制父进程的用户页表。对于用户进程空间中的虚拟地址,这里有几种情况需要考虑:
- 其一、该虚拟地址对应的页表项不存在,则无需在子进程中进程映射
- 其二、该虚拟地址对应的页表项存在且`PTE_V`位有效,则需要为子进程分配内存,并复制父进程对应的物理页,最后将该虚拟地址与新分配的物理内存映射进子进程的用户页表
- 其三、该虚拟地址对应的页表项存在且但`PTE_V`位无效这是因为pke中采用了预映射的机制此时父进程的页表项中所描述的地址不是真实的物理地址而是一个`vmr_t`结构体,该结构体描述着该段物理内存在对应文件中的位置,会在`page_fault`中被使用。在此只需要将父进程页表项所描述的vmr_t结构体地址赋值给子页表项。
- 其四、该虚拟地址对应着用户栈由于我们在proc_pagetable函数中已近为其分配了内存此时只需要复制父进程的用户栈。
至此我们进一步完善了对fork的支持。接下来我们来看进程间的同步。</br>
wait是最为基础的同步操作。wait函数在进程列表中进行遍历寻找该进程为尚未完成的任意子进程/或pid指定的子进程若存在且子进程处于`PROC_ZOMBIE`状态,则释放该子进程资源并返回。若子进程不处于`PROC_ZOMBIE`状态,则将父进程的状态设为`PROC_SLEEPING`,将父进程的等待状态设为`WT_CHILD`继而调用schedule。
从父进程调用wait开始进程是如何调度与切换的呢下面我们一起一探究竟。如下是一张wait调用的流程图</br></br>
<div style="text-align: center; width: 1000px; ">
<img src="lab_figures/final/wait_flow.png" alt="app5_1" style="margin: 0 auto;" />
</div>
首先wait的本质仍是系统调用父进程会进入trap_entry并在属于他的内核栈中保存trapframe在上文中我们对该段代码进行过讨论这里需要记住的是该trapframe中保存了父进程的sepc而当使用sret从管理员模式下返回时pc会被设置为sepc的值。
接着进入`do_wait`的代码这里不妨假其子进程状态不处于PROC_ZOMBIE故而在将父进程状态设置完成后进入schedule代码
void
schedule(void) {
bool intr_flag;
struct proc_struct *next;
local_intr_save(intr_flag);
{
currentproc->need_resched = 0;
if (currentproc->state == PROC_RUNNABLE) {
sched_class_enqueue(currentproc);
}
if ((next = sched_class_pick_next()) != NULL) {
sched_class_dequeue(next);
}
if (next == NULL) {
next = idleproc;
shutdown(0);
}
next->runs ++;
if (next != currentproc) {
proc_run(next);
}
}
local_intr_restore(intr_flag);
}
Schedule会选取下一个进程随即进入switch_to这又是一段汇编代码
# void switch_to(struct proc_struct* from, struct proc_struct* to)
.globl switch_to
switch_to:
# save from's registers
STORE ra, 0*REGBYTES(a0)
STORE sp, 1*REGBYTES(a0)
STORE s0, 2*REGBYTES(a0)
STORE s1, 3*REGBYTES(a0)
STORE s2, 4*REGBYTES(a0)
STORE s3, 5*REGBYTES(a0)
STORE s4, 6*REGBYTES(a0)
STORE s5, 7*REGBYTES(a0)
STORE s6, 8*REGBYTES(a0)
STORE s7, 9*REGBYTES(a0)
STORE s8, 10*REGBYTES(a0)
STORE s9, 11*REGBYTES(a0)
STORE s10, 12*REGBYTES(a0)
STORE s11, 13*REGBYTES(a0)
# restore to's registers
LOAD ra, 0*REGBYTES(a1)
LOAD sp, 1*REGBYTES(a1)
LOAD s0, 2*REGBYTES(a1)
LOAD s1, 3*REGBYTES(a1)
LOAD s2, 4*REGBYTES(a1)
LOAD s3, 5*REGBYTES(a1)
LOAD s4, 6*REGBYTES(a1)
LOAD s5, 7*REGBYTES(a1)
LOAD s6, 8*REGBYTES(a1)
LOAD s7, 9*REGBYTES(a1)
LOAD s8, 10*REGBYTES(a1)
LOAD s9, 11*REGBYTES(a1)
LOAD s10, 12*REGBYTES(a1)
LOAD s11, 13*REGBYTES(a1)
ret
这段代码保存了切换所前运行进程的14个寄存器到当前进程的上下文context此时当前进程的ra中存储的返回地址为函数schedule中`proc_run`下一行的地址。同时将下一个需要运行的进程的上下文装入各个寄存器中。若下一个进程为才被fork出来的子进程则由于在之前`copy_thread`中所设置的proc->context.ra = (uintptr_t)forkret;被加载的进程将进入函数forkret就此切换至另一进程运行。
当子进程执行完毕,它将执行`do_exit`,在`do_exit`中会将当前子进程的状态设置为PROC_ZOMBIE并且对其父进程的等待状态进行判断若父进程处于等待状态则使用`wakeup_proc`函数唤醒父进程。若当前执行exit进程存在子进程需要为其重新指定父进程。
当父进程被唤醒后,进入`wakeup_proc`的代码,在`wakeup_proc`函数中恢复了父进程的运行状态随即再次调用schedule函数。如上文所述schedule再次加载父进程的上下文还记的方才父进程上下文中的ra寄存器的值吗父进程将根据此值返回schedule中`proc_run`的下一行地址继续上次的代码并返回schedule的调用函数`do_wai`t。`do_wait`里我们将再次查看子进程的state若为PROC_ZOMBIE则返回。至此wait系统调用执行完毕。
----------
## 实验二 信号量 ##
### 应用: ###
到目前为止我们已近实现了进程间的简单同步。接下来我们更进一步考虑信号量的实现。App5_2的代码如下
#include<stdio.h>
#include "libuser.h"
#define N 2
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
int items=0;
void producer(void)
{
while(1)
{
if(items==5*N) break;
produce_item();
down(&empty); //空槽数目减1相当于P(empty)
down(&mutex); //进入临界区相当于P(mutex)
insert_item(); //将新数据放到缓冲区中
up(&mutex); //离开临界区相当于V(mutex)
up(&full); //满槽数目加1相当于V(full)
}
}
void consumer()
{
while(1)
{
down(&full); //将满槽数目减1相当于P(full)
down(&mutex); //进入临界区相当于P(mutex)
remove_item(); //从缓冲区中取出数据
up(&mutex); //离开临界区相当于V(mutex)
up(&empty); //将空槽数目加1 相当于V(empty)
consume_item(); //处理取出的数据项
}
}
int main(){
int pid;
int i;
for(i=0; i<2; i++){
pid=fork();
if(pid==0||pid==-1) //子进程或创建进程失败均退出
{
break;
}
}
if(pid==0) {
//
printf("printf 5_2 child %d\n",getpid());
consumer(getpid());
}else{
//
printf("printf 5_2 father %d \n",getpid());
producer();
}
return 0;
}
上述代码是一个简单的生产者消费者程序,由一个父进程创建两个子进程,父进程作为producer而子进程作为consumer。这里维护了三个信号量,mutex作为互斥信号量,为临界区提供互斥访问,empty用来维护空闲缓冲区,full则用来维护被填充的缓存区。
### 实验二任务: ###
实验二任务描述:
实现`__down`函数,支持app5_2.c的运行
实验二预期输出:
$ spike obj/pke app/elf/app5_2
预期得到输出中,父进程生产的数量等于两个子进程消费的数量之和。运行脚本:
$ python3 ./pke-final-2
预期得到输出:
build pk : OK
running app5_2 : OK
test sema : OK
Score: 20/20
### 实验二提示: ###
在程序执行的过程中,任务常常会因为某一条件没有达成而进入等待状态,具到的上述的例子,当producer发现没有空闲的缓存区即empty不足时,或者consumer发现full不足时,二者均会进入等待状态。等待条件得到满足,然后继续运行。这种机制,我们可以使用等待队列来实现,等待某一条件的进程在条件未满足时加入到等待队列当中,当条件满足时在遍历对应的队列唤醒进程,并且将进程从队列中删除。
在此,我们定义结构体wait_t如下:
typedef struct {
struct proc_struct *proc;
uint64_t wakeup_flags;
wait_queue_t * wait_queue;
list_entry_t wait_link;
}wait_t;
proc指向因条件不满足而被加入队列的进程,wakeup_flags对该未满足的条件进行描述,wait_queue指向此wait_t单元所属于的等待队列,wait_link同之前实验中的各类link一样用来组织链表的链接。
对于一个等待队列,封装了一系列的操作,我们对如下几个操作的代码进行阅读。</br>
首先,初始化`wait_t`,将一个进程封装入`wait_t`结构体:
void
wait_init(wait_t *wait, struct proc_struct *proc) {
wait->proc = proc;
wait->wakeup_flags = WT_INTERRUPTED;
list_init(&(wait->wait_link));
}
然后是`wakeup_wait`首先将指定的wait单元从等待队列中删除为其赋值新的wakeup_flags最后唤醒进程。
void
wakeup_wait(wait_queue_t *queue, wait_t *wait, uint64_t wakeup_flags, bool del) {
if (del) {
wait_queue_del(queue, wait);
}
wait->wakeup_flags = wakeup_flags;
wakeup_proc(wait->proc);
}
`wait_current_set`,修改当前进程的状态,并加入等待队列。
void
wait_current_set(wait_queue_t *queue, wait_t *wait, uint64_t wait_state) {
kassert(currentproc != NULL);
wait_init(wait, currentproc);
currentproc->state = PROC_SLEEPING;
currentproc->wait_state = wait_state;
wait_queue_add(queue, wait);
}
有了这些基本操作,我们就能在等待队列的基础上实现信号量,对于信号量,我们定义如下:
typedef struct semaphore{
intptr_t vaddr;
int value;
wait_queue_t wait_queue;
} semaphore_t;
vaddr是信号量在用户空间的地址&empty的值。这里我们将信号量的数组`sema_q`维护在内核空间中并通过vaddr将它与用户变量唯一关联。value为信号量的值`wait_queue`为信号量对应的等待队列。</br>
当用户程序中调用down/up时会调用至系统调用`sys_sema_down/sys_sema_up`,以`sys_sema_down`为例:
void sys_sema_down(intptr_t sema_va){
semaphore_t * se;
if((se=find_sema(sema_va))==NULL){
se=alloc_sema(sema_va);
}
down(se);
}
首先它会查看该用户变量是否已经有对应的信号量若不存在则为其分配信号量。alloc_sema的代码如下
semaphore_t* alloc_sema(intptr_t sema_va){
int found=0;
semaphore_t * se;
int value=0;
copyin((pte_t *)currentproc->upagetable,(char *)&value,sema_va,sizeof(int));
for(se=sema_q;se<&sema_q[NSEMA];se++){
if(se->vaddr==0){
se->vaddr=sema_va;
sem_init(se,value);
found=1;
break;
}else
{
continue;
}
}
if(found==0){
panic("no sema alloc\n");
}
return se;
}
它使用函数copyin利用用户页表得到用户变量所对应的值并用该值初始化信号量。至此完成信号量的分配。
接下来我们来关注对于信号量的操作up的实现
static __noinline void __up(semaphore_t *sem, uint64_t wait_state) {
bool intr_flag;
local_intr_save(intr_flag);
{
wait_t *wait;
if ((wait = wait_queue_first(&(sem->wait_queue))) == NULL) {
sem->value ++;
}
else {
kassert(wait->proc->wait_state == wait_state);
wakeup_wait(&(sem->wait_queue), wait, wait_state, 1);
}
}
local_intr_restore(intr_flag);
}
首先,它从等待队列中取出头节点,若不存在,则说明没有进程在等待,那么直接增加信号量的值。若取出的头节点不为空,则判断该节点是否处于等待状态,然后唤醒该节点。
相应的在down的代码中需要执行相反的逻辑首先需要判断value的值是否大于0若value值大于0则可以直接进行减操作。若否则需要将当前进程加入等待队列并设置其state与wait_state。接着需要调用schedule。当schedule返回时需要对唤醒标准进行判断并将进程从等待队列中删除。
----------
### 提交课设 ###
至此为止,你已经完成了本实验的所有代码,运行脚本./pke-final
$ python3 ./pke-final
预期得到如下输出,然后提交你的代码吧!
build pk : OK
running app5_1 : OK
test fork : OK
running app5_2 : OK
test sema : OK
Score: 40/40