跳过正文
S02E04: 操作系统并发(1): 分时复用

S02E04: 操作系统并发(1): 分时复用

·9879 字·20 分钟· loading · loading · · 草稿
StudyBase 笔记 OS 操作系统
目录
笔记-操作系统 - 这篇文章属于一个选集。
§ 11: 本文

在一个单CPU系统中,真正的并行是不可能的,并发(多个进程在一段时间内同时运行)是通过分时复用 (Time-sharing) CPU来实现的。那么,如何让多个进程在一定时间段内同时运行,互相抢占CPU?中断为我们的操作系统内核提供了切换执行流的能力,而分页机制则为每个进程提供了独立的虚拟地址空间。本实验将结合这两者,实现一个支持多进程并发执行的操作系统内核。内核调度器 (Scheduler) 会快速地在多个进程之间切换,每个进程运行一个极短的时间片 (Time Slice),从而在宏观上营造出所有进程在同时运行的假象。

为了实现这个目标,我们需要完成以下几个关键模块:

  1. 进程切换 (Context Switch):实现内核的核心调度功能。我们将实现 schedule() 负责调度, proc_yield() 负责让进程主动让出CPU,这是实现并发的基础。
  2. 进程创建 (Process Creation):通过实现 fork() 系统调用,赋予用户程序动态创建新进程的能力。
  3. 进程终结 (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: 标记进程的当前状态。我们关心RUNNINGREADY。进程切换负责将当前进程从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 是由中断机制自动压栈并形成的上下文的地址。

进程切换的完整流程
#

  1. 进程A调用 proc_yield():

    • 进程A可以是在用户态通过系统调用进入 proc_yield(),也可以是内核代码直接调用。
    • proc_yield() 将进程A的PCB状态设置为 READY
    • proc_yield() 执行 int $0x81 指令。
  2. 中断处理启动:

    • CPU响应中断,硬件自动将EFLAGSCSEIP等压入进程A的内核栈。
    • 控制权跳转到我们在 trap.S 中为0x81号中断编写的入口点 irq129
    • irq129 将其他压栈,形成一个完整的Context结构体。此时,栈顶指针ESP就是这个Context的地址。
  3. 调度器 schedule() 执行:

    • 通用中断处理函数 irq_handle 被调用,它发现中断号是 0x81,于是调用 schedule(ctx)。这里的 ctx 参数就是刚刚在进程A内核栈上形成的上下文地址。
    • schedule()做的第一件事,就是将传入的 ctx 地址保存到当前进程(进程A)的PCB中:current_proc->ctx = ctx;。这样,我们就记录下了进程A被冻结的瞬间。
    • schedule() 开始遍历全局的pcb数组,寻找一个状态为 READY 的进程。为了公平性,遍历应该从当前进程的下一个位置开始,并循环回到开头。假设它找到了进程B。
    • 我们的系统保证总有至少一个READY进程(比如内核自身的空闲进程),所以这个循环总能找到目标。在只有一个可运行进程的情况下,它可能会找到自己然后又切换到自己,这也是允许的。
  4. 执行切换: 调度器找到了进程B,接下来调用 proc_run(proc_B) 函数:将全局当前进程指针更新为进程B,然后设置页目录地址、内核栈地址,最后使用进程B的PCB中存储的上下文指针 proc_B->ctx 来执行 irq_iretirq_iret 指令从 proc_B->ctx 指向的地址(也就是进程B的内核栈)弹出所有寄存器的值,并恢复到CPU中。CPU的EIP指向了进程B上次被中断时的位置,执行流无缝地切换到了进程B。至此,一次完整的进程切换结束。

  5. 进程A的未来: 进程A的上下文被安全地保存在它的PCB中。当未来的某个时刻,调度器决定再次运行进程A时,它会重复步骤4和5,只不过这次使用的是进程A的ctxirq_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执行流之外的、周期性的外部信号源来触发调度。在我们的系统中,这个信号源就是时钟中断

本部分的任务主要有两个:

  1. 实现抢占: 将时钟中断与我们已经实现的 schedule() 调度器结合,实现基于时间片 (Time Slice) 的抢占式调度。
  2. 优化阻塞操作: 改进内核中等待事件(如键盘输入、定时)的实现。之前的处理虽然摆脱了让CPU一直工作的轮询模式,但也只是让CPU停机等待终端,本质上还是浪费了CPU资源。我们将把这些等待操作改为调用 proc_yield(),从而让CPU有机会去执行其他进程。

通过时钟中断实现抢占
#

我们的硬件(QEMU模拟的)被配置为每隔大约10毫秒触发一次时钟中断。这意味着无论CPU正在执行用户态还是内核态的何种代码,每隔10毫秒,执行流都会被强制中断,并跳转到我们在IDT中注册的时钟中断处理函数。这为我们提供了一个完美的抢占时机:我们只需要在时钟中断的处理函数 timer_handle()(位于 kernel/src/timer.c)中,调用 proc_yield() 即可。

让我们分析一下调用后会发生什么:

  1. 用户进程A正在运行。
  2. 时钟中断发生。CPU硬件自动保存关键寄存器,并跳转到内核的中断处理入口。
  3. 中断处理流程最终调用到 timer_handle()
  4. !!在 timer_handle() 内部,更新系统内部时间(tick)后,我们调用 proc_yield()
  5. proc_yield() 将当前进程A的状态设置为 READY,然后执行 int 0x81,主动触发调度。
  6. 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()后,会引入一个微妙但重要的问题。思考以下场景:

  1. 系统中所有用户进程都恰好在等待输入,因此都在循环调用getchar(),内部执行proc_yield()
  2. proc_yield() 的实现要求必须在关中断环境下调用int 0x81,以保证上下文保存和切换的原子性。
  3. 我们内核的“空闲进程”(init_user_and_go中的while (1) proc_yield();循环)也在做同样的事情——关中断并调用proc_yield()
  4. 整个系统中所有可被调度的进程在获得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()一样。为了实现这个效果,子进程在被创建的瞬间,其状态必须是父进程的完美克隆。这主要包括两个方面:

  1. 独立的虚拟地址空间:子进程拥有和父进程完全相同内容的内存副本。这包括代码段、数据段、堆和栈。重要的是,这必须是一个副本,而非共享。子进程修改自己的变量,不会影响到父进程。
  2. 相同的执行上下文:子进程的CPU寄存器状态(除了EAX返回值寄存器)与父进程在调用fork()陷入内核时的状态完全一致。这意味着子进程的指令指针(EIP)也指向fork()返回后的那条指令。

准备工作:建立父子关系
#

既然引入了父子进程的概念,我们的核心数据结构——进程控制块 (PCB) proc_t——也需要能够体现这种关系。我们为PCB添加两个成员:

  • proc_t *parent: 一个指向其父进程PCB的指针。对于由内核直接创建的初始进程,这个指针应为NULL
  • int child_num: 记录该进程当前拥有的子进程数量。

proc_allocproc_init中我们需要确保这两个成员被正确初始化:parent设为NULLchild_num设为0

核心实现:复制进程状态
#

为了实现 fork(),我们需要两个核心的辅助函数:一个负责复制内存,另一个负责复制包括CPU上下文在内的整个进程状态。

vm_copycurr(PD *pgdir) 复制虚拟地址空间
#

这个函数的目标是将当前进程的用户空间内存,完整地复制到 pgdir 所代表的新页表中。这也就要求,参数 pgdir 已经是一个新用vm_alloc分配的,只有内核空间[0, PHY_MEM)恒等映射的页表。

  1. 遍历用户地址空间: 用户程序的虚拟地址空间范围是 [PHY_MEM, USR_MEM)。写一个循环,遍历这个范围内的每一个虚拟页。
  2. 检查父进程映射: 在循环中,对每一个虚拟页地址 va,使用 vm_walkpte() 检查它在当前进程(父进程)的页表中是否存在一个有效的页表项。如果不存在,说明父进程没有使用这一页,直接跳过即可。
  3. 为子进程分配和映射: 如果父进程中存在有效的映射,就在新页表 pgdir 中,将虚拟地址 va 映射到物理页上(通过 vm_map())。确保映射的权限(读/写/用户等)与父进程PTE中的权限保持一致。
  4. 复制页面内容: 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。

  1. 复制地址空间: 调用 vm_copycurr(proc->pgdir) 来完成整个用户内存的复制。同时,将父进程的堆顶指针 brk 直接赋值给子进程。

  2. 复制执行上下文: 这是最关键的一步。我们需要回答一个问题:当fork()系统调用执行到这里时,父进程的用户态上下文被保存在哪里?我们考察fork的工作流程: fork()是一个系统调用,通过int 0x80触发。当中断发生时,CPU和我们的中断处理代码会将所有用户态寄存器作为一个 Context 结构体,压入到当前进程的内核栈顶。因此,我们只需要将父进程内核栈顶的这个 Context 结构体,完整地复制到子进程(proc)的内核栈顶即可。由于proc是刚通过proc_alloc()创建的,其ctx指针已经指向了正确的位置。一个简单的结构体赋值就能完成这个任务:proc->kstack->ctx = current->kstack->ctx;

  3. 设置子进程的返回值: 克隆完成后,父子进程的上下文完全一样。我们需要手动制造出唯一的不同点:fork()的返回值。返回值通常通过EAX寄存器传递。

    • 修改刚刚复制到子进程上下文中的EAX字段,将其值设置为 0
    • proc->ctx->eax = 0;
  4. 更新父子关系:

    • 设置子进程的父进程指针:proc->parent = proc_curr();
    • 增加父进程的子进程计数:proc_curr()->child_num++;

3.3 组装系统调用:sys_fork()
#

有了强大的辅助函数,sys_fork 的实现就变得非常直观了。

实现思路 (kernel/src/syscall.c):

  1. 分配PCB: 调用 proc_alloc() 为子进程申请一个新的PCB。如果返回NULL,说明资源不足,fork失败,应返回 -1
  2. 复制状态: 调用 proc_copycurr(new_proc),将当前进程(父进程)的状态完整地复制给新创建的子进程。
  3. 加入就绪队列: 调用 proc_addready(new_proc),将子进程的状态设置为READY,这样调度器在下一次调度时就有可能选择它来运行。
  4. 返回PID: 向父进程返回新创建的子进程的PID (new_proc->pid)。这个返回值会被系统调用处理框架自动放入父进程的EAX寄存器中。

代码实现任务笔记
#

1. 修改 proc_t 并初始化
#

  • 文件: kernel/include/proc.h
    • 任务: 取消 parentchild_num 成员的注释。
  • 重要: 运行 make clean
  • 文件: kernel/src/proc.c (proc_alloc)
    • 任务: 在函数中添加对 parent (设为 NULL) 和 child_num (设为 0) 的初始化代码。

2. 实现 vm_copycurr()
#

  • 文件: kernel/src/vme.c
  • 任务:
    • 用一个for循环遍历从 PHY_MEMUSR_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 来加载并运行 ping3dfstest 样例,观察父子进程是如何交替或并行执行的。

你还可以按照指导,为 sys_exit_groupsys_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) 的概念。进程的“死亡”被分为两个阶段:

  1. 第一次死亡 (执行 exit): 进程停止执行用户代码,进入内核执行 exit。内核将其大部分资源(如用户空间内存)标记为可释放,但保留最核心的结构——进程控制块(PCB)。此时,进程的状态被设置为ZOMBIE。它不再参与CPU调度,仅仅是一个保留了退出信息(如PID、退出状态码)的“空壳”。
  2. 第二次死亡 (父进程执行 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):

  1. 设置状态: 将proc的状态标记为 ZOMBIE,并将其退出码 exit_code 设置为传入的 exitcode
  2. 处理子进程: 遍历整个pcb数组。如果发现某个进程的parent指针指向的是即将死去的proc,就将该子进程的parent指针设置为 NULL,让它成为孤儿。

API 2: proc_findzombie(proc_t *proc)
#

这个函数供wait调用,用于寻找一个可被回收的子僵尸进程。

实现思路 (kernel/src/proc.c):

  1. 遍历PCB数组: 遍历pcb数组,寻找满足以下两个条件的进程: a. 它的parent指针指向proc(即,它是proc的子进程)。 b. 它的状态是ZOMBIE
  2. 返回结果: 如果找到了,返回该僵尸子进程的PCB指针。如果遍历完都没有找到,返回 NULL

4.2 系统调用实现
#

sys_exit_group(int status)
#

实现思路 (kernel/src/syscall.c):

  1. 变为僵尸: 调用 proc_makezombie(proc_curr(), status),将当前进程转变为僵尸进程。
  2. 放弃CPU: exit调用是永不返回的。进程变为僵尸后,必须放弃CPU且永不被调度。我们通过触发0x81调度中断来实现这一点。调度器schedule在选择下一个进程时,会自动跳过ZOMBIE状态的进程。
    • 执行 INT(0x81);
  3. 防御性代码: 理论上,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):

  1. 检查有无子进程: 检查当前进程的child_num。如果为0,说明它没有任何子进程,wait调用应立即失败。直接返回-1。
  2. 循环等待: 使用一个无限循环 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 所做的一切:

  1. 释放内存: 调用 vm_teardown(proc->pgdir)。这个函数应该负责释放该进程的所有用户空间物理页、页表,以及页目录本身。
  2. 释放内核栈: 调用 kfree(proc->kstack) 释放为该进程分配的内核栈。
  3. 重置PCB状态: 将PCB的状态设置为 UNUSED,使其可以被 proc_alloc 再次分配。
  4. 清理其他字段(可选但推荐): 将 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: 设置statusexit_code,遍历pcb数组将子进程变为孤儿。
    • proc_findzombie: 遍历pcb数组,寻找parent是当前进程且statusZOMBIE的进程。

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

  1. 由于切换通过中断完成,而中断将上下文保存在内核栈,所以此时进程的上下文在内核栈 ↩︎

Reply by Email
hhikr
作者
hhikr
未来人,宇宙人或超能力者
笔记-操作系统 - 这篇文章属于一个选集。
§ 11: 本文

相关文章

草稿
S02E03: 内存管理的实现
·13453 字·27 分钟· loading · loading
StudyBase 笔记 OS 操作系统
操作系统进阶笔记
草稿
S02E02: OS的中断处理
·14557 字·30 分钟· loading · loading
StudyBase 笔记 OS 操作系统
操作系统进阶笔记
S02E01: 操作系统的启动
·11165 字·23 分钟· loading · loading
StudyBase 笔记 OS 操作系统
操作系统进阶笔记