跳转到内容

进程抽象

在前面的实验中,我们将任务看作是正在执行的程序片段。而在本章中,我们将进一步完善任务的概念,引入标准的进程 (Process) 抽象。

kernel/src/task/task.rs 中,我们定义了 TaskControlBlock (TCB) 来表示一个进程。它是操作系统管理进程的核心数据结构,也常被称为 进程控制块 (Process Control Block, PCB)

pub struct TaskControlBlock {
    // Immutable
    /// Process identifier
    pub pid: PidHandle,
    /// Kernel stack corresponding to PID
    pub kernel_stack: KernelStack,
    /// Mutable
    inner: UPSafeCell<TaskControlBlockInner>,
}

PidHandleKernelStack 是初始化后不再变化的字段,可能发生变化的则放入 TaskControlBlockInner 中,如 Trap 上下文的物理页号 trap_cx_ppn, 上下文 task_cx 和应用地址空间 memory_set 等,注意上下文和 Trap 上下文存在区别,分别用于进程调度和特权级切换,保存的内容也分别是内核态执行状态和用户态执行状态。

内部状态 TaskControlBlockInner 包含了一个进程在运行过程中需要维护的所有动态状态,我们可以将其字段按功能分为三类:

  1. 执行状态

    • task_cx: 任务上下文,用于任务切换。
    • trap_cx_ppn: Trap 上下文所在的物理页号。
    • task_status: 当前进程的状态(如 Ready, Running, Zombie)。
  2. 资源状态

    • memory_set: 进程的地址空间管理结构。
    • fd_table: 文件描述符表(用于文件 I/O)。
    • heap_bottom, program_brk: 用户堆的起始地址和当前断点。
  3. 关系状态

    • parent: 指向父进程的弱引用。
    • children: 子进程的 TCB 列表。
    • exit_code: 进程退出时的状态码。

此处我们设计 PidHandle 来管理对 pid 的分配,与先前的 FrameAllocator 非常相似。PID_ALLOCATORKSTACK_ALLOCATOR 都是对 RecycleAllocator 的全局例化,KernelStack::new 被公有函数 kstack_alloc 所取代,原理不变仍是将 KSTACK_ALLOCATOR 分配得到的内核栈插入全局的内核地址空间 KERNEL_SPACE 中,使用 insert_franed_area 实现。

pub struct RecycleAllocator {
    current: usize,
    recycled: Vec<usize>,
}
/// allocate a new kernel stack
pub fn kstack_alloc() -> KernelStack {
    let kstack_id = KSTACK_ALLOCATOR.exclusive_access().alloc();
    let (kstack_bottom, kstack_top) = kernel_stack_position(kstack_id);
    KERNEL_SPACE.exclusive_access().insert_framed_area(
        kstack_bottom.into(),
        kstack_top.into(),
        MapPermission::R | MapPermission::W,
    );
    KernelStack(kstack_id)
}

随着进程功能的完善,我们需要引入新的进程状态:

pub enum TaskStatus {
    UnInit,
    Ready,
    Running,
    Zombie, // 僵尸态
}

进程的状态流转图更新为:

UnInit -> Ready <-> Running -> Zombie

关于为什么需要 Zombie 状态,注意与第三章的区别。在本章中,进程退出后不会立即销毁,而是进入 僵尸 (Zombie) 状态。这是一个中间状态,表示进程已经结束运行,但其内核栈和 PID 等资源尚未完全释放。

这样设计的目的是为了保留子进程的退出信息 (如 exit_code)。父进程可以通过 wait 系列系统调用来获取这些信息,从而判断子进程是正常退出还是异常终止,并据此做出相应的处理。只有当父进程回收了僵尸子进程后,该进程彻底销毁。

简单的梳理,便于同学们更好理解源码。进程的生命周期主要由以下三个核心操作驱动:

创建新进程的方法 TaskControlBlock::new 通常用于创建初始进程initproc。主要步骤包括:

  1. 解析 ELF 文件,创建地址空间 (MemorySet::from_elf)。
  2. 分配一个新的 PID 和内核栈。
  3. 初始化任务上下文 (task_cx),设置入口地址。
  4. 在内核栈顶构造初始的 Trap 上下文 (TrapContext),确保进程能正确进入用户态。

复制进程 TaskControlBlock::fork 是 Unix 系统中经典的进程创建方式。主要步骤包括:

  1. 复制地址空间:创建父进程地址空间的副本(代码段共享,数据段复制)。
  2. 复制文件描述符:克隆父进程的 fd_table
  3. 建立亲缘关系:将新进程的 parent 指向当前进程,并将其加入当前进程的 children 列表。
  4. 设置内核栈:子进程的 trap_cx.kernel_sp 指向其自己的内核栈。
  5. 修改返回值:修改子进程 Trap 上下文中的 a0 寄存器为 0,从而实现 fork 在子进程返回 0 的语义。

加载新程序 TaskControlBlock::exec 用于将当前进程替换为一个新的程序。主要步骤包括:

  1. 保留身份:保留当前的 PID 和父子关系。
  2. 替换镜像:解析新的 ELF 文件,替换原有的地址空间 memory_set
  3. 重置状态:设置新的用户栈、堆边界,并重置 Trap 上下文为新程序的入口状态。