lab4 实验报告 [BUAA-OS]
Lab4实验报告
Part1. 思考题
Thinking 4.1 系统调用的实现
- 内核在保存现场的时候是如何避免破坏通用寄存器的?
- 系统陷入内核调用后可以直接从当时的a3参数寄存器中得到用户调用msyscall留下的信息吗?
- 我们是怎么做到让sys开头的函数“认为”我们提供了和用户调用msyscall时同样的参数的?
- 内核处理系统调用的过程对Trapframe做了哪些更改?这种修改对应的用户态的变化是什么?
- 每当触发中断或异常时需要进入内核,我们在Exercise 3.10中将
.text.exc_gen_entry
段放到0x80000180为OS提供异常处理程序的入口,内核会在kern/entry.S
的exc_gen_entry
通过SAVE_ALL
宏完成现场保存。SAVE_ALL
宏会将所有通用寄存器的值压入栈中(除了k0
会被用来保存sp
寄存器的值和部分CP0寄存器的值),从而避免了破坏通用寄存器的值。
1 | .macro SAVE_ALL |
-
陷入内核在保存现场时
$a0-$a3
寄存器的值未发生改变,因此可以直接从这几个寄存器中获取用户调用msyscall
留下的信息。但随后执行handle_sys
函数时,将sp
寄存器的值传到$a0
中作为do_syscall
的参数,因此$a0
寄存器的值被修改,无法直接得到。 -
在用户态调用
syscall_*()
函数时,前四个参数会被存入寄存器$a0-$a3
,而多余的参数(如果有)会被存入栈中。
当用户调用 msyscall 触发系统调用后,CPU 会切换到内核态并跳转到异常处理入口。在内核态中,异常处理程序会保存用户态的寄存器值,包括$a0-$a3
和栈指针 sp,以便稍后使用。
在内核中,handle_sys
函数会处理系统调用。它会从保存的寄存器$a0-$a3
中取出前四个参数,同时从用户栈帧中取出多余的参数(如果有)。这些参数会被传递给do_syscall
函数,从而完成系统调用的实际处理。
这样,内核能够完整地还原用户调用 msyscall 时传递的所有参数(包括寄存器中的参数和栈中的参数),并将它们传递给内核中的系统调用处理函数。 -
栈中储存的EPC寄存器值会加上4,从而在异常处理完成后,程序会从异常处理函数的下一行继续执行。
1 | tf->cp0_epc += 4 |
另外,$v0
寄存器的值会被设置为系统调用的返回值。
1 | tf->regs[2] = func(arg1, arg2, arg3, arg4, arg5) |
Thinking 4.2 envid2env 的实现
思考 envid2env 函数: 为什么 envid2env 中需要判断 e->env_id != envid 的情况?如果没有这步判断会发生什么情况?
查看kern/env.c
文件中mkenvid()
函数
1 | u_int mkenvid(struct Env *e) { |
可以看出,低10位的(e - envs)
表示当前进程在envs
数组中的索引,高位是一个递增的值,每次调用mkenvid()
函数,这个值都会增加。
1 | int envid2env(u_int envid, struct Env **penv, int checkperm) { |
envid2env()
函数中使用ENVX(envid)
宏来获取envid
的低10位,再通过envs
数组来获取对应的Env
结构体指针,所以如果某一进程完成后被销毁,则其进程控制块会被插回env_free_list
中,再次创建进程时,有可能会复用这个进程控制块,并赋值不同的envid
。如果没有判断e->env_id != envid
,则如果传入的envid
属于已被销毁的进程,也能够成功获取到对应的Env
结构体指针,从而造成错误。
Thinking 4.3 mkenvid 的函数细节
思考下面的问题,并对这个问题谈谈你的理解:请回顾 kern/env.c 文件中 mkenvid() 函数的实现,该函数不会返回 0,请结合系统调用和 IPC 部分的实现与 envid2env() 函数的行为进行解释。
在mkenvid()
函数中,++i
会使得mkenvid()
函数返回的envid
值高位不为0,因此不会返回0。在IPC调用envid2env()
函数时,envid
为0表示当前进程的env_id
。如果需要通信的进程Benv_id
为0,则envid2env()
函数会返回当前进程A的Env
结构体指针,无法找到进程B的Env
结构体指针。执行fork()
函数时,envid
为0表示子进程的返回值,父进程的返回值是子进程的envid
,如果子进程的env_id
为0,则会导致父进程无法获取子进程的Env
结构体指针,智能够获取当前进程的Env
结构体指针。
Thinking 4.4 fork 的返回结果
关于 fork 函数的两个返回值,下面说法正确的是:
A、fork 在父进程中被调用两次,产生两个返回值
B、fork 在两个进程中分别被调用一次,产生两个不同的返回值
C、fork 只在父进程中被调用了一次,在两个进程中各产生一个返回值
D、fork 只在子进程中被调用了一次,在两个进程中各产生一个返回值
C
Thinking 4.5 用户空间的保护
我们并不应该对所有的用户空间页都使用 duppage 进行映射。那么究竟哪些用户空间页应该映射,哪些不应该呢?请结合 kern/env.c 中 env_init 函数进行的页面映射、 include/mmu.h 里的内存布局图以及本章的后续描述进行思考。
- 内核部分是所有用户进程共享的,因此不需要映射。
- ULIM到UTOP之间页面的内存和页表是所有进程共享的,且用户进程无权限访问,无需使用
duppage()
进行映射。 - UTOP到USTACKTOP间是用户态下的异常处理栈,不会在处理COW异常时调用
fork()
,所以user exception stack不需要映射。 - USTACKTOP以下是用户进程的栈空间,
duppage()
函数会将其映射到子进程中。
Thinking 4.6 vpt 的使用
vpt 和 vpd 的作用是什么?怎样使用它们?
1 | // user/inlude/lib.h |
vpd是一个指向页目录首地址的指针,可以通过它访问页目录表pde。vpt是一个指向页表首地址的指针,可以通过它访问页表项pte。
从实现的角度谈一下为什么进程能够通过这种方式来存取自身的页表?
OS在初始化页表时,将页表的物理地址映射到特定的虚拟地址空间UVPT
中,使得用户进程可以通过vpt
和vpd
来访问自己的页表。
它们是如何体现自映射设计的?
vpd的地址是UVPT + (PDX(UVPT) << PGSHIFT)
,而vpt的地址是UVPT
,它们都映射到同一页表中。也就是说,页表中的某一页是页目录。这样,用户进程可以通过vpt
和vpd
来访问自己的页表,实现自映射设计。
进程能够通过这种方式来修改自己的页表项吗?
不能。vpt
和vpd
是只读的,用户进程不能通过它们来修改自己的页表项。
Thinking 4.7 页写入异常-内核处理
在 do_tlb_mod 函数中,你可能注意到了一个向异常处理栈复制 Trapframe 运行现场的过程,请思考并回答这几个问题:
这里实现了一个支持类似于“异常重入”的机制,而在什么时候会出现这种“异常重入”?
出现COW异常时,内核会调用do_tlb_mod()
函数来处理写入异常。此时,内核已经处于异常处理状态,如果在处理过程中再次发生异常,就会出现“异常重入”的情况。
内核为什么需要将异常的现场 Trapframe 复制到用户空间?
这是因为,当内核处理COW异常时,do_tlb_mod()
函数会返回到user/lib/fork.c
中的cow_entry()
。页写入异常主要的处理过程是在用户态下由cow_entry
完成,因而需要复制栈,使得用户程序能够访问异常发生时的状态信息。
Thinking 4.8 页写入异常-用户处理-1
在用户态处理页写入异常,相比于在内核态处理有什么优势?
- 尽量减少内核出现错误的可能,即使程序崩溃,错误和崩溃页会被限制在单个进程内,不会影响整个OS的稳定。
- MOS操作系统遵从微内核的设计理念,在用户态进行页面的拷贝和写入操作,能够提高系统的性能,减少上下文切换的开销。
Thinking 4.9 页写入异常-用户处理-2
为什么需要将 syscall_set_tlb_mod_entry 的调用放置在 syscall_exofork 之前?
syscall_exofork()
函数会创建一个新的进程,子进程需要指向新的进程的env
结构体。此时,子进程的页表和父进程的页表是相同的,因此需要将syscall_set_tlb_mod_entry()
函数放置在syscall_exofork()
之前,以便在子进程中处理写入异常时能够正确地修改页表项。
如果放置在写时复制保护机制完成之后会有怎样的效果?
syscall_set_tlb_mod_entry()
如果放置在写时复制保护机制完成之后,则栈空间将被设置为COW保护,在访问栈空间时会触发写入异常,此时还没有执行syscall_set_tlb_mod_entry()
,因此无法处理写入异常,导致程序崩溃。
Part2. 难点分析
系统调用的实现
在用户态触发一条系统调用,需要:
- 在用户态函数(
msyscall
/do_syscall
)中保存现场($sp
等通用寄存器),并跳转到内核态入口(entry.S
) - 内核态入口(
entry.S
)捕获异常、切换到内核栈、保存用户 Trapframe - 在
kern/syscall_all.c
中分发到对应的sys_*
函数 sys_*
函数完成系统调用的具体操作,并返回结果- 在
kern/entry.S
中恢复现场,返回到用户态函数(msyscall
) - 在用户态函数(
msyscall
)中恢复现场,并返回结果到用户态程序 - 需要注意的是,内核态的现场保存是“重入”的,不能破坏原有的现场(
SAVE_ALL
宏)
envid2env
的实现
将用户传来的 envid
映射到内核中的 struct Env *
指针,需要:
- 在
kern/syscall_all.c
的 IPC、sys_exofork
等函数里调用,并验证目标进程是否“存在”且“可操作” - 如果
envid
不合法,或者跨子/父权限越界,必须返回-E_BAD_ENV
,防止恶意或误操作
fork
的返回结果
fork()
在父进程中返回新子进程的envid
(> 0),在子进程中返回 0,而出错时返回 < 0。- 父子进程都调用
fork()
,但只有父进程会返回新子进程的envid
,子进程则返回 0 - 这两个返回值是通过
sys_exofork
和syscall_set_tlb_mod_entry
函数实现的 - 需要在
sys_exofork
内修改子进程 Trapframe 中的$v0=0
,而在父进程则保留为子envid
。
进程间通信
进程间通信主要通过两个系统调用实现:sys_ipc_recv
和 sys_ipc_try_send
:
-
sys_ipc_recv
:- 接收方进程调用此函数并传递待映射的虚拟地址。
- 设置当前进程的
env_ipc_recving
为1,表示正在等待接收消息。 - 将
env_ipc_dstva
设置为传入的虚拟地址。 - 阻塞当前进程,将其移出调度队列。
- 调度其他进程运行,等待发送方进程发送消息。
-
sys_ipc_try_send
:- 发送方进程调用此函数,传入接收方进程的
envid
、待发送的值、待映射的虚拟地址以及权限位。 - 使用
envid2env
验证接收方进程是否存在且可操作。 - 检查接收方进程的
env_ipc_recving
是否为 1:如果为 0,表示接收方未准备好接收消息,则调度其他进程运行,等待轮转到发送方进程时再次尝试发送。如果为 1,则完成以下操作:1. 将发送的值传递给接收方进程。2. 如果虚拟地址不为 0,则完成页面的映射。3. 清除接收方的env_ipc_recving
标志,并将其重新加入调度队列。
- 发送方进程调用此函数,传入接收方进程的
-
页面复制与映射:
- 当需要复制一个物理页面并建立用户虚拟地址与新物理页面的映射时:
- 使用
page2kva
将新物理页面转换为内核态虚拟地址。 - 使用
memcpy
进行数据拷贝。 - 调用
page_insert
将新物理页面映射到用户虚拟地址。
- 使用
- 当需要复制一个物理页面并建立用户虚拟地址与新物理页面的映射时:
页写入异常——内核处理
实现写时复制(COW)机制:
- 在
duppage
时把原父进程页设置为PTE_COW
且清除PTE_D
(可写) - 当进程写此页时触发 TLB Mod 异常,进入内核的
do_tlb_mod
(tlbex.c
) - 在异常处理里检测到
PTE_COW
,分配新物理页、复制数据、更新页表并让子进程继续执行
页写入异常——用户处理
分为两部分:
- 处理入口 (
cow_entry
):从内核栈恢复到用户栈时,需要修改 Trapframe 并跳回cow_entry
,检查触发页的 PTE - 逻辑恢复:
cow_entry
要在用户态正确地调用sys_set_tlb_mod_entry
和其他系统调用,才能让后续写操作透明完成
Part3. 实验体会
Lab4帮助我深入理解了进程间通信(IPC)和系统调用的流程,并学会了如何实现写时复制(COW)机制。