在一个单CPU系统中,真正的并行是不可能的,并发(多个进程在一段时间内同时运行)是通过分时复用 (Time-sharing) CPU来实现的。那么,如何让多个进程在一定时间段内同时运行,互相抢占CPU?中断为我们的操作系统内核提供了切换执行流的能力,而分页机制则为每个进程提供了独立的虚拟地址空间。本实验将结合这两者,实现一个支持多进程并发执行的操作系统内核。内核调度器 (Scheduler) 会快速地在多个进程之间切换,每个进程运行一个极短的时间片 (Time Slice),从而在宏观上营造出所有进程在同时运行的假象。
为了实现这个目标,我们需要完成以下几个关键模块:
- 进程切换 (Context Switch):实现内核的核心调度功能。我们将实现
schedule()负责调度,proc_yield()负责让进程主动让出CPU,这是实现并发的基础。 - 进程创建 (Process Creation):通过实现
fork()系统调用,赋予用户程序动态创建新进程的能力。 - 进程终结 (Process Termination):通过实现
exit()和wait()系统调用,让进程能够正常退出,并允许父进程管理和回收子进程的资源。
第一步:实现主动让出的进程切换 #
理论:如何实现进程切换 #
进程切换的本质,是CPU执行流的切换。这意味着CPU需要停止执行当前进程的代码,转而去执行另一个进程的代码。要实现这一点,关键在于两步:在切换走之前,必须完整地保存当前进程运行的所有状态(上下文Context),主要包括CPU的寄存器状态(如EIP指令指针、ESP栈指针、EAX/EBX等通用寄存器)和页表地址(CR3寄存器)等;再进行前一步的逆向,加载目标进程之前保存好的上下文到CPU的各个寄存器中,从而让CPU从目标进程上次被中断的地方继续执行。
不难注意到这个模式与中断处理流程非常相似。这里复习一下中断上下文的结构。不过在进程切换中保存的上下文和恢复的上下文是不同的两个进程。我们仍然使用中断处理的机制来实现进程切换——引入一个新的、自定义的软件中断,中断号为129(0x81)。这个中断将专门用于触发进程调度。当一个进程想要主动让出CPU时(我们称之为yielding),它只需执行 int 0x81 指令。CPU会像响应任何其他中断一样,陷入内核态,保存上下文,并跳转到我们在中断描述符表 (IDT) 中为0x81号中断指定的处理函数。
这个处理函数就是我们的核心调度器 schedule()。
实践:在框架代码内实现进程切换 #
关键数据结构与函数 #
proc_t结构体 (PCB):status: 标记进程的当前状态。我们关心RUNNING和READY。进程切换负责将当前进程从RUNNING变为READY,然后选择另一个READY进程变为RUNNING。ctx: 一个Context*类型的指针。这个上下文,具体来说,是该进程下一次被恢复执行时所需的上下文的地址。当进程被切换出去时,内核会把当前内核栈上1保存的Context地址存放在这里。
-
proc_addready(proc_t *proc): 一个简单的辅助函数,将一个进程的状态设置为READY,表示它已经可以被调度器选择了。 -
proc_yield():供内核或用户进程调用的函数,用于主动放弃CPU。它的实现很简单:将当前进程状态从RUNNING变为READY,然后触发int 0x81中断,把控制权交给调度器。 -
schedule(Context *ctx):0x81号中断的中断处理函数,是调度的核心。参数ctx是由中断机制自动压栈并形成的上下文的地址。
进程切换的完整流程 #
-
进程A调用
proc_yield():- 进程A可以是在用户态通过系统调用进入
proc_yield(),也可以是内核代码直接调用。 proc_yield()将进程A的PCB状态设置为READY。proc_yield()执行int $0x81指令。
- 进程A可以是在用户态通过系统调用进入
-
中断处理启动:
- CPU响应中断,硬件自动将
EFLAGS、CS、EIP等压入进程A的内核栈。 - 控制权跳转到我们在
trap.S中为0x81号中断编写的入口点irq129。 irq129将其他压栈,形成一个完整的Context结构体。此时,栈顶指针ESP就是这个Context的地址。
- CPU响应中断,硬件自动将
-
调度器
schedule()执行:- 通用中断处理函数
irq_handle被调用,它发现中断号是0x81,于是调用schedule(ctx)。这里的ctx参数就是刚刚在进程A内核栈上形成的上下文地址。 schedule()做的第一件事,就是将传入的ctx地址保存到当前进程(进程A)的PCB中:current_proc->ctx = ctx;。这样,我们就记录下了进程A被冻结的瞬间。schedule()开始遍历全局的pcb数组,寻找一个状态为READY的进程。为了公平性,遍历应该从当前进程的下一个位置开始,并循环回到开头。假设它找到了进程B。- 我们的系统保证总有至少一个
READY进程(比如内核自身的空闲进程),所以这个循环总能找到目标。在只有一个可运行进程的情况下,它可能会找到自己然后又切换到自己,这也是允许的。
- 通用中断处理函数
-
执行切换: 调度器找到了进程B,接下来调用
proc_run(proc_B)函数:将全局当前进程指针更新为进程B,然后设置页目录地址、内核栈地址,最后使用进程B的PCB中存储的上下文指针proc_B->ctx来执行irq_iret;irq_iret指令从proc_B->ctx指向的地址(也就是进程B的内核栈)弹出所有寄存器的值,并恢复到CPU中。CPU的EIP指向了进程B上次被中断时的位置,执行流无缝地切换到了进程B。至此,一次完整的进程切换结束。 -
进程A的未来: 进程A的上下文被安全地保存在它的PCB中。当未来的某个时刻,调度器决定再次运行进程A时,它会重复步骤4和5,只不过这次使用的是进程A的
ctx,irq_iret会把CPU状态恢复到int $0x81指令的下一条语句,proc_yield()函数返回,进程A就像什么都没发生过一样继续执行。
初始化用户进程 #
理解了上述流程后,我们就可以修改内核的启动函数 init_user_and_go 了。之前,我们是直接通过 proc_run 一次性地切换到用户进程。现在,我们应该将内核本身也视为一个进程,通过调度机制来启动第一个用户进程:加载完用户程序后,调用 proc_addready(proc) 将其标记为可调度;将 proc_run() 调用替换为一个无限循环 while (1) proc_yield();。这会将内核的主进程变成一个“空闲进程” (Idle Process),当没有其他用户进程可运行时,调度器就会切换回它,然后它会立刻调用 proc_yield() 再次让出CPU,发起新一轮的调度。
第二步:实现抢占式多任务 #
在第一部分,我们实现了一个协作式多任务 (Cooperative Multitasking) 系统。在这种模型下,进程的切换完全依赖于进程自身的“自觉”——即主动调用 yield() 系统调用来放弃CPU。这种模型的缺点显而易见:如果一个进程陷入死循环或者执行长时间的计算任务而不主动放弃CPU,整个系统中的其他进程都将无法执行,导致系统“卡死”。
为了构建一个更健壮的操作系统,我们需要实现抢占式多任务 (Preemptive Multitasking)。核心思想是,操作系统内核必须有能力强制剥夺当前进程的CPU使用权,并将其分配给另一个进程,而无需等待当前进程的许可。
要实现抢占,我们需要一个独立于CPU执行流之外的、周期性的外部信号源来触发调度。在我们的系统中,这个信号源就是时钟中断。
本部分的任务主要有两个:
- 实现抢占: 将时钟中断与我们已经实现的
schedule()调度器结合,实现基于时间片 (Time Slice) 的抢占式调度。 - 优化阻塞操作: 改进内核中等待事件(如键盘输入、定时)的实现。之前的处理虽然摆脱了让CPU一直工作的轮询模式,但也只是让CPU停机等待终端,本质上还是浪费了CPU资源。我们将把这些等待操作改为调用
proc_yield(),从而让CPU有机会去执行其他进程。
通过时钟中断实现抢占 #
我们的硬件(QEMU模拟的)被配置为每隔大约10毫秒触发一次时钟中断。这意味着无论CPU正在执行用户态还是内核态的何种代码,每隔10毫秒,执行流都会被强制中断,并跳转到我们在IDT中注册的时钟中断处理函数。这为我们提供了一个完美的抢占时机:我们只需要在时钟中断的处理函数 timer_handle()(位于 kernel/src/timer.c)中,调用 proc_yield() 即可。
让我们分析一下调用后会发生什么:
- 用户进程A正在运行。
- 时钟中断发生。CPU硬件自动保存关键寄存器,并跳转到内核的中断处理入口。
- 中断处理流程最终调用到
timer_handle()。 - !!在
timer_handle()内部,更新系统内部时间(tick)后,我们调用proc_yield()。 proc_yield()将当前进程A的状态设置为READY,然后执行int 0x81,主动触发调度。schedule()函数被调用。它保存进程A的完整上下文,然后选择另一个READY状态的进程B,并切换到进程B运行。
通过这个流程,进程A在没有主动让出的情况下被“抢占”了CPU,而进程B获得了执行的机会。这个由时钟中断触发的调度周期,就是我们操作系统的时间片 (Time Slice) 。
优化阻塞操作 #
在之前的实现中,当内核需要等待某个事件时,我们使用了sti(); hlt(); cli();这样的指令序列。例如:
kernel/src/serial.c中的getchar():当串口没有输入时,CPU执行hlt指令进入暂停状态,等待下一次中断(比如键盘中断)来唤醒。kernel/src/syscall.c中的sys_sleep():在一个循环中不断检查时间,如果没到期就执行hlt。
在单进程模型下,这是合理的。但在多进程环境下,当一个进程因为等待I/O而暂停时,CPU完全可以被用来执行另一个不需要等待的进程。让CPU hlt 是对资源的极大浪费。我们将这些hlt的调用,替换为proc_yield()。
将阻塞操作改为proc_yield()后,会引入一个微妙但重要的问题。思考以下场景:
- 系统中所有用户进程都恰好在等待输入,因此都在循环调用
getchar(),内部执行proc_yield()。 proc_yield()的实现要求必须在关中断环境下调用int 0x81,以保证上下文保存和切换的原子性。- 我们内核的“空闲进程”(
init_user_and_go中的while (1) proc_yield();循环)也在做同样的事情——关中断并调用proc_yield()。 - 整个系统中所有可被调度的进程在获得CPU后,都会立即关闭中断。这意味着,CPU的中断标志位 (IF) 将永远没有机会被设置为1。即使硬件发出了时钟中断信号,CPU也会忽略它。结果就是,抢占机制失效,整个系统陷入一种“活锁”:进程在不断切换,但外部事件一个也处理不了。
我们需要指定一个“兜底”的进程,它的核心职责之一就是确保中断最终能够被打开。这个角色最适合由内核的空闲进程来扮演。因此,我们需要修改init_user_and_go函数末尾的空闲循环:
- 旧设计:
while (1) proc_yield(); - 新设计:
sti(); while (1) ;——明确地开启中断后,进入一个无限循环(忙等)。
现在,当所有用户进程都因调用proc_yield()而处于关中断状态时,调度器最终会选择运行空闲进程。空闲进程一旦运行,就会立即通过sti()打开中断。此时,之前被屏蔽的时钟中断就可以被CPU响应了。时钟中断处理程序会触发抢占,调用schedule(),并将CPU切换给其他进程。
这个设计确保了系统中总有一个地方可以处理待处理的外部中断。代价是,内核空闲进程自身不再主动让出CPU,它必须等待下一次时钟中断来将它抢占掉。对于一个“空闲”进程来说,这个代价是完全可以接受的。
第三步:实现进程复制 (fork())
#
目前,只有我们的内核能够在启动时创建和加载进程。为了让操作系统变得更加通用和强大,用户程序自身必须有能力创建新的进程。在Unix-like系统中,fork() 是实现这一功能的核心系统调用。fork() 的行为堪称操作系统中的一种“魔法”:
- 当一个进程(我们称之为父进程)调用
fork()时,内核会创建一个与它几乎一模一样的子进程。 - 对于父进程,
fork()调用会返回新创建的子进程的PID。 - 对于子进程,
fork()调用会返回0。 - 如果进程创建失败(例如,系统资源耗尽),父进程会收到
-1。
最奇特的一点是,子进程的执行并不是从main函数开始的,而是从 fork() 系统调用返回的地方开始。在它被调度器第一次运行时,它必须就像是刚刚也调用了fork()一样。为了实现这个效果,子进程在被创建的瞬间,其状态必须是父进程的完美克隆。这主要包括两个方面:
- 独立的虚拟地址空间:子进程拥有和父进程完全相同内容的内存副本。这包括代码段、数据段、堆和栈。重要的是,这必须是一个副本,而非共享。子进程修改自己的变量,不会影响到父进程。
- 相同的执行上下文:子进程的CPU寄存器状态(除了
EAX返回值寄存器)与父进程在调用fork()陷入内核时的状态完全一致。这意味着子进程的指令指针(EIP)也指向fork()返回后的那条指令。
准备工作:建立父子关系 #
既然引入了父子进程的概念,我们的核心数据结构——进程控制块 (PCB) proc_t——也需要能够体现这种关系。我们为PCB添加两个成员:
proc_t *parent: 一个指向其父进程PCB的指针。对于由内核直接创建的初始进程,这个指针应为NULL。int child_num: 记录该进程当前拥有的子进程数量。
在proc_alloc和proc_init中我们需要确保这两个成员被正确初始化:parent设为NULL,child_num设为0。
核心实现:复制进程状态 #
为了实现 fork(),我们需要两个核心的辅助函数:一个负责复制内存,另一个负责复制包括CPU上下文在内的整个进程状态。
vm_copycurr(PD *pgdir) 复制虚拟地址空间
#
这个函数的目标是将当前进程的用户空间内存,完整地复制到 pgdir 所代表的新页表中。这也就要求,参数 pgdir 已经是一个新用vm_alloc分配的,只有内核空间[0, PHY_MEM)恒等映射的页表。
- 遍历用户地址空间: 用户程序的虚拟地址空间范围是
[PHY_MEM, USR_MEM)。写一个循环,遍历这个范围内的每一个虚拟页。 - 检查父进程映射: 在循环中,对每一个虚拟页地址
va,使用vm_walkpte()检查它在当前进程(父进程)的页表中是否存在一个有效的页表项。如果不存在,说明父进程没有使用这一页,直接跳过即可。 - 为子进程分配和映射: 如果父进程中存在有效的映射,就在新页表
pgdir中,将虚拟地址va映射到物理页上(通过vm_map())。确保映射的权限(读/写/用户等)与父进程PTE中的权限保持一致。 - 复制页面内容:
a. 现在,同一个虚拟地址
va在父进程和子进程中分别映射到了不同的物理页。 b. 使用memcpy()将父进程的页面内容,完整地复制到子进程的新物理页中。你需要通过页表查询得到这两个物理地址来完成复制。
void vm_copycurr(PD* pgdir) {
for (uintptr_t va = PHY_MEM; va < USR_MEM; va += PGSIZE) {
PTE* pte = vm_walkpte(vm_curr(), va, 0);
if (pte && pte->present) {
vm_map(pgdir, va, PGSIZE, (pte->val) & 7);
void* pa = vm_walk(pgdir, va, 0);
memcpy(pa, (void*)va, PGSIZE);
}
}
}
proc_copycurr(proc_t *proc) 复制完整进程状态
#
这个函数是 fork() 的“心脏”,它调用 vm_copycurr,并完成剩余状态(堆,上下文)的复制。参数proc是使用proc_alloc()新创建的子进程的PCB。
-
复制地址空间: 调用
vm_copycurr(proc->pgdir)来完成整个用户内存的复制。同时,将父进程的堆顶指针brk直接赋值给子进程。 -
复制执行上下文: 这是最关键的一步。我们需要回答一个问题:当
fork()系统调用执行到这里时,父进程的用户态上下文被保存在哪里?我们考察fork的工作流程:fork()是一个系统调用,通过int 0x80触发。当中断发生时,CPU和我们的中断处理代码会将所有用户态寄存器作为一个Context结构体,压入到当前进程的内核栈顶。因此,我们只需要将父进程内核栈顶的这个Context结构体,完整地复制到子进程(proc)的内核栈顶即可。由于proc是刚通过proc_alloc()创建的,其ctx指针已经指向了正确的位置。一个简单的结构体赋值就能完成这个任务:proc->kstack->ctx = current->kstack->ctx;。 -
设置子进程的返回值: 克隆完成后,父子进程的上下文完全一样。我们需要手动制造出唯一的不同点:
fork()的返回值。返回值通常通过EAX寄存器传递。- 修改刚刚复制到子进程上下文中的
EAX字段,将其值设置为 0。 proc->ctx->eax = 0;
- 修改刚刚复制到子进程上下文中的
-
更新父子关系:
- 设置子进程的父进程指针:
proc->parent = proc_curr(); - 增加父进程的子进程计数:
proc_curr()->child_num++;
- 设置子进程的父进程指针:
3.3 组装系统调用:sys_fork()
#
有了强大的辅助函数,sys_fork 的实现就变得非常直观了。
实现思路 (kernel/src/syscall.c):
- 分配PCB: 调用
proc_alloc()为子进程申请一个新的PCB。如果返回NULL,说明资源不足,fork失败,应返回-1。 - 复制状态: 调用
proc_copycurr(new_proc),将当前进程(父进程)的状态完整地复制给新创建的子进程。 - 加入就绪队列: 调用
proc_addready(new_proc),将子进程的状态设置为READY,这样调度器在下一次调度时就有可能选择它来运行。 - 返回PID: 向父进程返回新创建的子进程的PID (
new_proc->pid)。这个返回值会被系统调用处理框架自动放入父进程的EAX寄存器中。
代码实现任务笔记 #
1. 修改 proc_t 并初始化
#
- 文件:
kernel/include/proc.h- 任务: 取消
parent和child_num成员的注释。
- 任务: 取消
- 重要: 运行
make clean。 - 文件:
kernel/src/proc.c(proc_alloc)- 任务: 在函数中添加对
parent(设为NULL) 和child_num(设为0) 的初始化代码。
- 任务: 在函数中添加对
2. 实现 vm_copycurr()
#
- 文件:
kernel/src/vme.c - 任务:
- 用一个
for循环遍历从PHY_MEM到USR_MEM的虚拟地址。 - 在循环内,使用
vm_walkpte检查当前进程(父进程)中该虚拟地址是否被映射。 - 如果已映射,为新页表
pgdir分配物理页 (kalloc),并建立映射 (vm_map),注意权限要一致。 - 使用
memcpy将父进程页面的数据复制到子进程的新页面。
- 用一个
3. 实现 proc_copycurr()
#
- 文件:
kernel/src/proc.c - 任务:
- 调用
vm_copycurr()复制内存。 - 直接复制
brk的值。 - 通过结构体赋值或
memcpy,将当前进程内核栈顶的Context复制到目标进程proc的内核栈顶。 - 关键: 将
proc->ctx->eax的值修改为0。 - 正确设置
proc->parent并递增proc_curr()->child_num。
- 调用
4. 实现 sys_fork()
#
- 文件:
kernel/src/syscall.c - 任务:
- 调用
proc_alloc()。如果失败,返回 -1。 - 调用
proc_copycurr()。 - 调用
proc_addready()。 - 返回新进程的 PID。
- 调用
验证与探索 #
完成实现后,你可以修改 init_user_and_go 来加载并运行 ping3 或 dfstest 样例,观察父子进程是如何交替或并行执行的。
你还可以按照指导,为 sys_exit_group 和 sys_wait 提供临时的“桩实现” (stub implementation),然后尝试运行更智能的 sh (shell)。这将让你初步体验到现代shell是如何通过 fork() 和 exec() (我们将在后续实现) 的组合来运行用户命令的。这会为你下一阶段学习进程的终结与等待打下坚实的基础。运行用户命令的。这会为你下一阶段学习进程的终结与等待打下坚实的基础。
实验目标:实现进程终结与等待 (exit() & wait())
#
在真实的操作系统中,进程的生命周期管理是一个精细的过程。一个进程的结束并不像看起来那么简单,它涉及到资源释放、状态通知以及与父进程的同步。我们将实现两个核心的系统调用来完成这个过程:
void exit(int status): 供进程调用以终止自身的执行。status是一个整数,用于告知其父进程它的退出状态(例如,0代表成功,非0代表某种错误)。int wait(int *status): 供父进程调用,用于等待其任何一个子进程结束。它会回收子进程的资源,并获取子进程通过exit()传递的退出状态。
“两次死亡”:进程与僵尸进程 #
实现进程退出时,有一个核心的挑战:一个正在执行exit()系统调用的进程,无法完全“自我销毁”。这是因为exit()的代码本身就在该进程的内核栈上运行,CPU也正在使用它的页表。如果在exit()执行过程中就释放了内核栈或页表,系统将立刻崩溃。
为了解决这个问题,操作系统引入了僵尸进程 (Zombie Process) 的概念。进程的“死亡”被分为两个阶段:
- 第一次死亡 (执行
exit): 进程停止执行用户代码,进入内核执行exit。内核将其大部分资源(如用户空间内存)标记为可释放,但保留最核心的结构——进程控制块(PCB)。此时,进程的状态被设置为ZOMBIE。它不再参与CPU调度,仅仅是一个保留了退出信息(如PID、退出状态码)的“空壳”。 - 第二次死亡 (父进程执行
wait): 父进程通过调用wait来“收尸”。当父进程wait到一个僵尸状态的子进程时,它会读取子进程的退出状态,然后彻底清理该子进程留下的所有资源,包括释放其PCB。至此,子进程才算从系统中完全消失。
孤儿进程问题 #
如果一个父进程在子进程之前退出了怎么办?这些子进程就成了孤儿进程 (Orphan Process)。在我们的必做实验中,为了简化设计,我们将采取一个“残酷”的策略:当一个进程exit时,我们会将它所有子进程的parent指针设为NULL。这些子进程从此就成了孤儿,当它们未来exit变成僵尸进程后,将再也没有父进程来为它们wait和收尸。它们将永远作为僵尸停留在系统中,直到系统重启。
这是一个有意的简化,它会导致资源泄漏。在更完整的操作系统中(例如选做内容或真实世界的Linux),孤儿进程会被一个特殊的“init”进程(通常是PID为1的进程)所“收养”,由init进程负责为它们wait。
4.1 准备工作与核心API #
修改proc_t
#
为了保存僵尸进程的退出状态,我们需要在PCB中增加一个字段。
- 文件:
kernel/include/proc.h - 任务: 取消
exit_code成员前的注释。 - 重要: 再次提醒,修改完头文件后务必
make clean。
API 1: proc_makezombie(proc_t *proc, int exitcode)
#
这个函数负责执行进程的“第一次死亡”。
实现思路 (kernel/src/proc.c):
- 设置状态: 将
proc的状态标记为ZOMBIE,并将其退出码exit_code设置为传入的exitcode。 - 处理子进程: 遍历整个
pcb数组。如果发现某个进程的parent指针指向的是即将死去的proc,就将该子进程的parent指针设置为NULL,让它成为孤儿。
API 2: proc_findzombie(proc_t *proc)
#
这个函数供wait调用,用于寻找一个可被回收的子僵尸进程。
实现思路 (kernel/src/proc.c):
- 遍历PCB数组: 遍历
pcb数组,寻找满足以下两个条件的进程: a. 它的parent指针指向proc(即,它是proc的子进程)。 b. 它的状态是ZOMBIE。 - 返回结果: 如果找到了,返回该僵尸子进程的PCB指针。如果遍历完都没有找到,返回
NULL。
4.2 系统调用实现 #
sys_exit_group(int status)
#
实现思路 (kernel/src/syscall.c):
- 变为僵尸: 调用
proc_makezombie(proc_curr(), status),将当前进程转变为僵尸进程。 - 放弃CPU:
exit调用是永不返回的。进程变为僵尸后,必须放弃CPU且永不被调度。我们通过触发0x81调度中断来实现这一点。调度器schedule在选择下一个进程时,会自动跳过ZOMBIE状态的进程。- 执行
INT(0x81);
- 执行
- 防御性代码: 理论上,
INT(0x81)之后,这个执行流就永远中断了。可以加上assert(0)或panic("zombie revived!")来确保如果代码意外地继续执行,系统会立刻报错。
关于sys_exit vs sys_exit_group: 如文档所述,在多线程模型中,exit()会终结单个线程,而exit_group()会终结整个进程(包含其所有线程)。在当前无线程的模型下,我们只关心进程级别的退出,因此实现sys_exit_group即可。
sys_wait(int *status)
#
这是父进程与子进程同步的关键。
实现思路 (kernel/src/syscall.c):
- 检查有无子进程: 检查当前进程的
child_num。如果为0,说明它没有任何子进程,wait调用应立即失败。直接返回-1。 - 循环等待: 使用一个无限循环
while (1)来不断尝试寻找一个已退出的子进程。 a. 寻找僵尸: 在循环内部,调用proc_findzombie(proc_curr())。 b. 处理僵尸: 如果找到了一个子僵尸进程 (zombie_child != NULL): i. 传递退出状态: 如果用户传入的status指针不为NULL,则将子僵尸进程的exit_code写入status指向的地址。 ii. 记录PID: 保存子僵尸进程的PID,因为这是wait的返回值。 iii. 收尸: 调用proc_free(zombie_child)来彻底释放该子进程的所有资源。 iv. 更新子进程计数: 将当前进程的child_num减一。 v. 返回PID:break循环或直接return已保存的PID。 c. 无僵尸,继续等待: 如果proc_findzombie返回NULL,说明有子进程但它们都还在运行。此时父进程应该阻塞自己,让出CPU。调用proc_yield()。
4.3 实现资源回收: proc_free()
#
这个函数执行进程的“第二次死亡”,是真正的资源回收站。
实现思路 (kernel/src/proc.c):
proc_free 的逻辑与 proc_alloc 相反。它需要撤销 proc_alloc 所做的一切:
- 释放内存: 调用
vm_teardown(proc->pgdir)。这个函数应该负责释放该进程的所有用户空间物理页、页表,以及页目录本身。 - 释放内核栈: 调用
kfree(proc->kstack)释放为该进程分配的内核栈。 - 重置PCB状态: 将PCB的状态设置为
UNUSED,使其可以被proc_alloc再次分配。 - 清理其他字段(可选但推荐): 将
pid,pgdir,kstack,ctx,brk等字段重置为0或-1,这是一种良好的编程习惯,有助于调试。
代码实现任务笔记 #
1. 修改 proc_t
#
- 文件:
kernel/include/proc.h - 任务: 取消
exit_code的注释。 - 重要:
make clean。
2. 实现 proc_makezombie() 和 proc_findzombie()
#
- 文件:
kernel/src/proc.c - 任务:
proc_makezombie: 设置status和exit_code,遍历pcb数组将子进程变为孤儿。proc_findzombie: 遍历pcb数组,寻找parent是当前进程且status为ZOMBIE的进程。
3. 实现 sys_exit_group() 和 sys_wait()
#
- 文件:
kernel/src/syscall.c - 任务:
sys_exit_group: 调用proc_makezombie然后INT(0x81).sys_wait: 实现循环等待逻辑,在找到僵尸子进程后,传递退出码、调用proc_free、更新child_num并返回PID;若未找到则proc_yield。
4. 实现 proc_free()
#
- 文件:
kernel/src/proc.c - 任务: 释放页目录 (
vm_teardown)、释放内核栈 (kfree)、重置PCB状态为UNUSED。
-
由于切换通过中断完成,而中断将上下文保存在内核栈,所以此时进程的上下文在内核栈 ↩︎