跳转到内容

任务管理分析

在上一节中,我们介绍了任务生命周期和 TCB 的概念。本节将深入探讨任务管理的具体实现细节,包括任务上下文的保存与恢复、任务切换机制以及全局任务管理器的设计。

在多任务系统中,操作系统需要频繁地在不同任务之间进行切换。为了保证任务在被切换回来时能够继续正确执行,我们需要保存任务被切换时的执行状态,即任务上下文 (Task Context)

kernel/src/task/context.rs 中,我们定义了 TaskContext 结构体:

//kernel/src/task/context.rs
#[repr(C)]
pub struct TaskContext {
    ra: usize,       // 返回地址寄存器
    sp: usize,       // 栈指针
    s: [usize; 12],  // callee-saved 寄存器 s0–s11
}

你可能会疑问,为什么 TaskContext 只保存了部分寄存器?这与 RISC-V 架构的函数调用约定(Calling Convention/ABI)有关。

类别寄存器负责方是否需要保存到 TaskContext
Caller-savedt0–t6, a0–a7, ra (部分情况)调用者保存__switch 被视为一个普通函数调用,调用者(编译器生成的代码)在调用前已经保存了这些寄存器(通常保存在栈上)。
Callee-saveds0–s11, sp, ra (部分情况)被调用者保存。作为被调用者(__switch),我们需要手动保存这些寄存器,以保证函数返回时状态一致。

简而言之,__switch 的执行过程在编译器看来就是一个普通的函数调用。因此,我们只需要配合编译器,手动保存那些编译器认为”被调用者有责任保存”的寄存器即可。

TaskContext 提供了初始化的方法:

// kernel/src/task/context.rs

// 零初始化,目前用于 _unused 占位符
pub fn zero_init() -> Self { ... }

// 创建初始任务上下文
pub fn goto_restore(kstack_ptr: usize) -> Self {
    extern "C" { fn __restore_sp(); }
    Self {
        ra: __restore_sp as usize,  // 任务第一次被调度时的返回地址
        sp: kstack_ptr,             // 内核栈顶(存放着第一次进入用户态所需的 TrapContext)
        s: [0; 12],
    }
}

goto_restore 方法巧妙地构造了一个”伪造”的调用历史。当调度器通过 __switch 切换到这个新任务并执行 ret 指令时,CPU 会跳转到 ra 寄存器中保存的地址,即 __restore_sp。随后,__restore_sp 会从内核栈恢复 TrapContext,并使用 sret 指令进入用户态,从而启动任务。


任务切换的核心逻辑由汇编函数 __switch 实现,它定义在 kernel/src/task/switch.S 中:

extern "C" {
    pub fn __switch(
        current_task_cx_ptr: *mut TaskContext, // 当前任务上下文指针
        next_task_cx_ptr: *const TaskContext,  // 下一个任务上下文指针
    );
}

使用汇编实现 __switch 是必须的,因为我们需要直接控制 CPU 寄存器和栈指针,并且不能受到编译器指令重排或自动寄存器分配的干扰。高级语言无法提供这种级别的控制。

__switch 的执行过程可以概括为:

  1. 保存现场:将当前的 ra, sp, s0-s11 寄存器保存到 current_task_cx_ptr 指向的内存位置(当前任务的 TCB 中)。
  2. 恢复现场:从 next_task_cx_ptr 指向的内存位置加载 ra, sp, s0-s11 寄存器(恢复下一个任务的状态)。
  3. 跳转执行:执行 ret 指令。此时 ra 寄存器已经变为下一个任务的返回地址,控制流随之转移到下一个任务。

TASK_MANAGER通过 lazy_static! 宏在内核中全局存在。

pub struct TaskManager {
    num_app: usize,
    inner: UPSafeCell<TaskManagerInner>,
}

pub struct TaskManagerInner {
    tasks: [TaskControlBlock; MAX_APP_NUM],
    current_task: usize,
}

你可能会问,为什么需要 UPSafeCell?这就是内部可变性所要做的事情。

Rust 的过程中,不可变引用(&T)和可变引用(&mut T)的排他性规则已经让你记忆犹新。 TaskManagerInner是为了修改其内部的状态(如切换当前任务、更改 TCB 状态),但全局单例拿到的往往是不可变引用 &TaskManager

在单核操作系统(Uniprocessor,UP)环境下,我们不担心多核并发导致的问题,只需在运行时借助内部可变性屏蔽掉 Rust 在编译期的借用检查。

// 获取调度器内部锁!
let mut inner = self.inner.exclusive_access(); 
inner.tasks[current].task_status = TaskStatus::Exited;
// 退出作用域或显式调用 drop(inner) 时,释放掉特权

该函数负责执行调度策略,决定下一个将要获得 CPU 使用权的任务。

在本章中,我们采用简单的 时间片轮转 (Round-Robin, RR) 调度算法。为了保证公平性,算法逻辑如下: 从当前任务 (current_task) 的下一个位置开始,循环遍历任务列表。一旦找到第一个状态为 TaskStatus::Ready 的任务,即将其作为下一个运行的任务返回。如果遍历一圈后仍未找到就绪任务,说明所有用户程序均已完成或退出。

fn find_next_task(&self) -> Option<usize> {
    let inner = self.inner.exclusive_access();
    let current = inner.current_task;
    (current + 1..current + self.num_app + 1)
        .map(|id| id % self.num_app)
        .find(|id| inner.tasks[*id].task_status == TaskStatus::Ready)
}

任务分发与上下文切换 run_next_task

Section titled “任务分发与上下文切换 run_next_task”

该函数负责执行实际的任务切换流程,包括状态更新和硬件上下文的切换。

find_next_task 成功返回下一个任务的 ID 后,run_next_task 将执行以下关键操作:

  1. 更新状态:将选中的下一个任务状态更新为 Running,并将调度器的 current_task 更新为该任务 ID。
  2. 切换上下文:构造当前任务和下一个任务的上下文指针,并调用汇编函数 __switch
fn run_next_task(&self) {
    if let Some(next) = self.find_next_task() {
        let mut inner = self.inner.exclusive_access();
        let current = inner.current_task;
        inner.tasks[next].task_status = TaskStatus::Running; // 将下一个任务标记为运行中
        inner.current_task = next;
        
        let current_task_cx_ptr = &mut inner.tasks[current].task_cx as *mut _;
        let next_task_cx_ptr   = &inner.tasks[next].task_cx as *const _;
        
        // 在切换前必须手动释放锁,否则会造成死锁!
        drop(inner);
        unsafe { __switch(current_task_cx_ptr, next_task_cx_ptr); }
    } else {
        panic!("All applications completed!");
    }
}