rCore OS Note - Chapter 3

第三章:多道程序与分时多任务, 阅读 rCore tutorial book v3 的笔记以及实践部分的实现与记录。

0. 资料汇总

隔了这么就才整到第三章, 毕竟最近杂活儿有点多, 另外第二章的编程题也是安排很不合理, 把后面的内容提前扔给读者结果第三章实验部分要推倒重来太浪费时间了。 更新这章的 OS 框架的时候变动的地方有些多, 尤其是 Makefile 部分以及 build.rs 部分和 rCore-Tutorial-Guide-2023S 中进行了同步以及微调。

1. Multi-program OS

Multi-program OS Details, rCore
Multi-program OS Details, rCore

要满足 任务切换 的要求, 所以程序需要预先被加载到一个固定的内存地址, 而不是所有程序共用一个入口地址。 rCore 添加了 user/build.py 读取 os/src/link_app.S 并对每个程序的地址空间进行更改以适应前述要求。

与 Trap 不同, Mult-program OS 中的任务切换发生在内核态 (S Mode) 且不涉及特权级切换, 通过 __switch 函数实现两个不同的 Task Context 的切换。

为什么 __switch 需要通过汇编的完成主要功能?
由于任务切换不涉及特权级的改变, 因而需要保存的上下文就比较少, 仅需要保存 ra 入口地址, sp 栈指针, 以及 Callee 负责寄存器 s0~s11 即可。 高级程序语言会在函数中自己使用一些通用的寄存器, 可能会发生意料之外的控制流, 另外 __switch 函数仅作为 label 存在不会被 Rust/C 编译器处理, 这些都是使用汇编完成 __switch 主体功能的原因。

2. Cooperative OS

Cooperative OS Details, rCore
Cooperative OS Details, rCore

Multi-program OS 仅仅介绍了任务切换中的 换栈 需求, 任务切换还需要指示任务的状态, 维护这一信息。 这里我仅记录初始化流程中的几个自己困惑的细节。

// os/src/task/mod.rs
for (i, task) in tasks.iter_mut().enumerate() {
    task.task_cx = TaskContext::goto_restore(init_app_cx(i));
    task.task_status = TaskStatus::Ready;
}

// os/src/task/context.rs
pub fn goto_restore(kstack_ptr: usize) -> Self {
    extern "C" {
        fn __restore();
    }
    Self {
        ra: __restore as usize,
        sp: kstack_ptr,
        s: [0; 12],
    }
}

TASK_MANAGER 通过 init_app_cx 函数初始化了每个 task 的内核栈, 而 goto_restore 的设计就非常巧妙, 这里并没有进入 trap.S__restore 标签处, 而是将任务栈的 ra 初始化为 __restore 地址, 这样当 __switch 完成后 ret 就能直接进入trap.S__restore 恢复到用户态继续执行任务。 相应的其中的 sp 指针被初始化为内核栈指针。

丢失的 mv sp, a0

  • 可以顺着控制流梳理一遍, 第一次用到 __switch 是初始化时运行 run_first_task 函数。 __switchunused 中的垃圾数据保存在了程序栈后切换到了第一个 task (此后就是内核栈和用户栈之间的切换了), 通过 ra 指向的 __restore 进行上下文恢复, 对于 task 而并不需要再次更新 sp, 该值已经在 __switch 中被恢复成该任务对应的内核指针了。

  • 另一个就是进入 trap 之后, __alltrap 最后进入 trap_handler 函数, 该函数最后会在返回参数的 a0 中保存之前存入的 TrapContext, 而在此之前有这么两句指令说明了 sp 就是这个 TrapContext。 因而无需再进行重复保存。

     # set input argument of trap_handler(cx: &mut TrapContext)
     mv a0, sp
     call trap_handler
    

3. Timesharing OS

Timesharing OS Details, rCore
Timesharing OS Details, rCore

4. 课后练习

这章的编程题还是有必要提前做一下的, 汲取了上一章的教训这次先看了 lab 部分, 发现 lab 有个要求是记录当前的正在运行的 task 的运行总时长, 这部分时间既包括内核态与用户态两部分的运行时间, 正好编程题包括了。 这部分可以选择性的完成几个基础练习, 难的部分再好好研究一下。

4.1 编程题

  1. 扩展内核, 能够显示操作系统切换任务的过程。

    只用在 os/src/task/mod.rsrun_next_task 以及 mark_current_exitedmark_current_suspend 加入打印就行, 另外在这里我对任务切换的流程进行了优化, 仅在当前 current != next 时才进行 Task Context 交换以及打印相关的信息, 这样能减少内核开销。

     // os/src/task/mod.rs
     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;
             ...
             // ch3-pro1
             if current != next {
                 println!("[kernel] task switch from {} to {}", current, next);
                 unsafe {
                     __switch(current_task_cx_ptr, next_task_cx_ptr);
                 }
             }
         }
         ...
     }
    

    除此之外 struct TaskManagerInner 中我添加了 alive_task_num 用以记录仍在 Ready 以及 Running 的 task 的数量, 仅在 alive_task_num > 1 的时候才在 mark_current_suspend 中添加打印以保持输出面板整洁以及系统开销最小。

    具体实现可以参考 commit#4f1dd08 以及 commit#208d863

  2. 扩展内核, 能够统计每个应用执行后的完成时间: 用户态完成时间和内核态完成时间。

    lab 相关的重点, sys_task_info 大部分信息其实都需要 task module 的支持。 另外, 每个 task 都是独立的, 因而需要在 struct TaskControlBlock 中增加记录 task 运行时间的变量。

     #[derive(Copy, Clone)]
     pub struct TaskControlBlock {
         pub task_status: TaskStatus,
         pub task_cx: TaskContext,
         pub user_time: usize,
         pub kernel_time: usize,
     }
    

    之后比较关键的是如何记录这个时间, 这个我在之前 chapter2 实现了类似的函数, 基本思路就是利用 riscv 的 mtime 寄存器, 需要有个特定的变量存储 mtime 寄存器的值并在每次进入该 task 时计算与现在的 mtime 的插值获取时间信息的变更。

    lab 中要求时间单位为 ms, 可以使用 timer.rsget_time_ms 函数。

     pub struct TaskManagerInner {
         /// task list
         tasks: [TaskControlBlock; MAX_APP_NUM],
         /// id of current `Running` task
         current_task: usize,
         /// the number of tasks that have not exit
         alive_task_num: usize,
         /// record time point
         checkpoint: usize,
     }
    
    内核态时间
    run_first_task 以及 mark_current_exitedmark_current_suspend 中更新信息, 另外需要在 task 退出时打印耗时。
    用户态时间
    用户态和内核态的分界处就是 trap, 因而在 trap_handler 的起始位置和末尾位置可分别作为 user time 的开始以及 user time 的结束。

    具体实现可以参考课后参考答案部分 以及 commit#5904c2f:sob: 我把 user_time_startuser_time_end 的位置搞反了, 后续所有的版本都要更改。

  3. 编写浮点应用程序A, 并扩展内核, 支持面向浮点应用的正常切换与抢占。

    这块没怎么搞懂, 看起来要加很多东西。

  4. 编写应用程序或扩展内核, 能够统计任务切换的大致开销。

    这里的参考答案是有问题的, __switch 之后会跳转到 __restore 恢复到用户态,后面那句只有下一次用户态 trap 后才会执行。 虽然不对我还是测试了一下, 发现 context switch 要花费几百毫秒, 这肯定是不可能的。 可行的办法是更改 goto_restore 函数, 将每个 __switch 调用的返回地址更改为 __pre_restore 并在这里插入一个更新 TaskControlBlock 中的 switch_time 的值, 这样才能统计不同的 task 的 switch context 开销。

    • 首先需要在 os/src/task/task.rsstruct TaskControlBlock 中插入统计每个 task 任务切换上下文开销的变量。

        // os/src/task/task/rs
        #[derive(Copy, Clone)]
        pub struct TaskControlBlock {
            pub task_status: TaskStatus,
            pub task_cx: TaskContext,
            pub syscall_times: [u32; MAX_SYSCALL_NUM],
            pub switch_time: usize,
            pub user_time: usize,
            pub kernel_time: usize,
        }
      
    • 更新 trap.S 并在尾部插入如下代码, 相应的需要更改 goto_restorera 地址为 __pre_restore

        __pre_restore:
            mv a0, sp
            call switch_cost
            mv sp, a0
            j __restore
      
    • context switch 的开销统计的相关函数如下。

        // os/src/task/mod.rs
        pub static mut SWITCH_TASK_START: usize = 0;
      
        pub unsafe fn __switch(current_task_cx_ptr: *mut TaskContext, next_task_cx_ptr: *const TaskContext) {
            SWITCH_TASK_START = get_time_us();
            switch::__switch(current_task_cx_ptr, next_task_cx_ptr);
            // 记录除了第一次运行外的 switch cost
            crate::task::update_switch_cost(get_time_us() - SWITCH_TASK_START);
        }
      
        pub fn update_switch_cost(cost: usize) {
            let mut inner = TASK_MANAGER.inner.exclusive_access();
            let current = inner.current_task;
            inner.tasks[current].switch_time += cost;
        }
        // os/src/trap/mod.rs
        #[no_mangle]
        pub unsafe extern "C" fn switch_cost (cx: &mut TrapContext) -> &mut TrapContext {
            crate::task::update_switch_cost(get_time_us() - SWITCH_TASK_START);
            cx
        }
      

    具体的实现和细节可以参考 commit#4e9425, 但当时做的时候遗漏了 __switch 计时函数中的 crate::task::update_switch_cost(get_time_us() - SWITCH_TASK_START); 导致内核只能记载程序 switch to not running 即程序开始第一次运行前的开销。 具体可以参考 Mars 在 rCore 相关章节的留言。

  5. 扩展内核,支持在内核态响应中断。

  6. 扩展内核,支持在内核运行的任务(简称内核任务),并支持内核任务的抢占式切换。

4.2 实验练习

获取任务信息

做完编程题的第二个就跑来做 lab 了, 事情比我想得要复杂一些。 这里需要声明一下 rCore-Tutorial-Guide 2023 实验指导书对应的 test repo 和当前的 rCore-Tutorial-Book-v3 3.6.0-alpha.1 文档 是有差异的, 如果你和我一样是按照 tutorial 一步步搭起来的 OS 请注意以下这些细节。

Guide 2023S 中提供的接口函数是如下形式, 在具体实现的时候由于 TaskInfo 中存在一个数组结构, 需要添加 #[derive(Clone, Copy)], 如果添加了 #[repr(C)] 修饰, 后续在 user 目录下的 TaskInfo 需要保持声明的一致性, 否则 os 部分的 sys_task_info 将无法正确赋值。

// os/src/syscall/process.rs
fn sys_task_info(ti: *mut TaskInfo) -> isize

struct TaskInfo {
    status: TaskStatus,
    syscall_times: [u32; MAX_SYSCALL_NUM],
    time: usize
}

我最开始设想在 TaskInfo 外在包裹一个 TaskInfoWrapper, 并将其声明为如下 case1 形式, 但问题也随之而来, 程序运行会出现 PageFault 错误。 而 case2 这样试图将 TaskInfo 作为可变量用 UPSafeCell 保护, 之后再通过 lazy_static! 宏声明一个 [TaskInfoWrapper;MAX_APP_NUM] 全局数组变量则会遇到 Copy Trait 未实现的问题。

// case1
struct TaskInfoWrapper {
    inner: UPSafeCell<[TaskInfo; MAX_APP_NUM]>,
}
// case2
struct TaskInfoWrapper {
    inner: UPSafeCell<TaskInfo>,
}

因而我想现阶段我没办法找到一种合适的办法构造一个全局数组变量去存储每个 task info, 那不如利用现有的资源, 每个 task 都维护了一个 TaskControlBlock 变量, 此前的编程题作业在这里面添加了 kernel_time 以及 user_time, 不如再增加一个 syscall_times 数组变量(与所给测例一致)。

// os/src/task/task.rs
#[derive(Copy, Clone)]
pub struct TaskControlBlock {
    pub task_status: TaskStatus,
    pub task_cx: TaskContext,
    pub syscall_times: [u32; MAX_SYSCALL_NUM],
    pub user_time: usize,
    pub kernel_time: usize,
}

剩下的事情就比较简单了, 给 sys_task_info 提供一个可以获取当前 task 的 TaskCOntrolBlock 的函数, 以及一个可以在 trap_handler 的 syscall 之前调用的增加 syscall_times 值的函数就能基本满足题干的要求, 我是这么实现的。

// os/src/task/mod.rs
pub fn get_current_task_block() -> TaskControlBlock {
    let inner = TASK_MANAGER.inner.exclusive_access();
    let current = inner.current_task;
    inner.tasks[current].clone()
}

pub fn update_task_syscall_times(syscall_id: usize) {
    let mut inner = TASK_MANAGER.inner.exclusive_access();
    let current = inner.current_task;
    inner.tasks[current].syscall_times[syscall_id] += 1;
}

// os/src/syscall/process.rs
pub fn sys_task_info(ti: *mut TaskInfo) -> isize {
    let task_block = get_current_task_block();
    unsafe {
        *ti = TaskInfo {
            status: task_block.task_status,
            syscall_times: task_block.syscall_times,
            time: task_block.kernel_time + task_block.user_time,
        };
    }
    0
}

具体实现可以参考 commit#d5f5555, 但该 commit 中我还对之前的 sys_write 的 checker 函数进行了更新。

Warning

需要注意 ch3_taskinfo.rs 需要单独运行测试, 否则 get_time 函数获取的几个时间点的差值会受到其他程序影响而产生很大偏差。 另外测例中的 println! 是调用了 flush 函数的, 因而在 rCore 2023S 提供的测试环境中确实会有两次 write 系统调用, 但是按照 tutorial 搭的 OS 现阶段仅有一次 write 系统调用, 若这个测试没通过可以自行修改一下。