ch3

2025年10月19日 · 4936 字 · 10 分钟

3.1多道程序放置

为了加载运行多个程序,需要每个用户程序被内核加载到内存中的起始地址都不同,本章将不同的应用程序链接到了操作系统的不同的位置,应用程序加载的位置是一个基址加上指定的偏移量

3.2任务切换

即应用在运行中主动或被动地交出 CPU 的使用权,内核可以选择另一个程序继续执行。 内核需要保证用户程序两次运行期间,任务上下文(如寄存器、栈等)保持一致。

上下文切换

操作系统需要“同时”运行远多于CPU核心数量的程序,通过快速地在多个任务之间切换,给用户造成所有任务都在并行执行的假象。

上下文切换便是操作系统为了在单个CPU上实现同时运行多个任务(进程或线程),而保存当前任务状态、并加载下一个任务状态的过程,需要保存包括CPU寄存器的值、程序计数器(PC),堆栈指针以及操作系统为该进程或线程维护的内存分配信息等

上下文切换:

  1. 中断当前任务:保存当前CPU所有寄存器的值和程序计数器到该任务的进程控制块 中。

  2. 更新队列:将当前任务的状态从“运行”改为“就绪”或“阻塞”,并放入相应的队列。

  3. 调度下一个任务:根据调度算法(如轮转、优先级),从就绪队列中选择一个任务来执行。

  4. 恢复上下文:从下一个任务的PCB中加载其之前保存的寄存器值、程序计数器、内存管理信息(如页表基址寄存器)等。

  5. 跳转执行:根据恢复的程序计数器,开始执行该任务。

上下文切换实现

//! Implementation of [`TaskContext`]

#[derive(Copy, Clone)]
#[repr(C)]
/// task context structure containing some registers
pub struct TaskContext {
    /// Ret position after task switching
    ra: usize,
    /// Stack pointer
    sp: usize,
    /// s0-11 register, callee saved
    s: [usize; 12],
}

impl TaskContext {
    /// Create a new empty task context
    pub fn zero_init() -> Self {
        Self {
            ra: 0,
            sp: 0,
            s: [0; 12],
        }
    }
    /// Create a new task context with a trap return addr and a kernel stack pointer
    pub fn goto_restore(kstack_ptr: usize) -> Self {
        extern "C" {
            fn __restore();
        }
        Self {
            ra: __restore as usize,
            sp: kstack_ptr,
            s: [0; 12],
        }
    }
}

TaskContext结构体就是保存上下文的结构体, 包括s0-s11寄存器, 栈指针sp, 返回地址ra

程序还没运行时,其上下文信息中的ra是ch2介绍的从trap恢复的__restore

任务切换的设计与实现

任务切换是来自两个不同应用在内核中的 Trap 控制流之间的切换

与 Trap 切换不同,它不涉及特权级切换;

与 Trap 切换不同,它的一部分是由编译器帮忙完成的;

与 Trap 切换相同,它对应用是透明的。

image-23.png 阶段[1]:在 Trap 控制流 A 调用 __switch 之前,A 的内核栈上只有 Trap 上下文和 Trap 处理函数的调用栈信息,而 B 是之前被切换出去的;

阶段[2]:A 在 A 任务上下文空间在里面保存 CPU 当前的寄存器快照;

阶段[3]:这一步极为关键,读取 next_task_cx_ptr 指向的 B 任务上下文,根据 B 任务上下文保存的内容来恢复 ra 寄存器、s0~s11 寄存器以及 sp 寄存器。只有这一步做完后, __switch 才能做到一个函数跨两条控制流执行,即 通过换栈也就实现了控制流的切换 。

阶段[4]:上一步寄存器恢复完成后,可以看到通过恢复 sp 寄存器换到了任务 B 的内核栈上,进而实现了控制流的切换。这就是为什么 __switch 能做到一个函数跨两条控制流执行。此后,当 CPU 执行 ret 汇编伪指令完成 __switch 函数返回后,任务 B 可以从调用 __switch 的位置继续向下执行。

__switch的实现:

# os/src/task/switch.S

.altmacro
.macro SAVE_SN n
    sd s\n, (\n+2)*8(a0)
.endm
.macro LOAD_SN n
    ld s\n, (\n+2)*8(a1)
.endm
    .section .text
    .globl __switch
__switch:
    # 阶段 [1]
    # __switch(
    #     current_task_cx_ptr: *mut TaskContext,
    #     next_task_cx_ptr: *const TaskContext
    # )
    # 阶段 [2]
    # save kernel stack of current task
    sd sp, 8(a0)
    # save ra & s0~s11 of current execution
    sd ra, 0(a0)
    .set n, 0
    .rept 12
        SAVE_SN %n
        .set n, n + 1
    .endr
    # 阶段 [3]
    # restore ra & s0~s11 of next execution
    ld ra, 0(a1)
    .set n, 0
    .rept 12
        LOAD_SN %n
        .set n, n + 1
    .endr
    # restore kernel stack of next task
    ld sp, 8(a1)
    # 阶段 [4]
    ret
// os/src/task/context.rs

pub struct TaskContext {
    ra: usize,
    sp: usize,
    s: [usize; 12],
}

a0 和 a1 是函数参数寄存器,在RISC-V调用约定中用来传递第一个和第二个参数。在__switch函数中,它们分别代表current_task_cx_ptr(当前任务上下文指针)和next_task_cx_ptr(下一个任务上下文指针)

  • sd sp, 8(a0):存储当前任务的堆栈指针(sp)到current_task_cx_ptr所指向的结构体的第二个位置(假设结构体的起始位置为0,每个存储单元为8字节,栈指针保存在偏移量为8字节的位置)。

  • sd ra, 0(a0):存储当前任务的返回地址(ra)到current_task_cx_ptr所指向的结构体的起始位置。

  • .set n, 0 和 .rept 12 循环:这是一个宏循环,它重复执行12次,用于保存寄存器s0到s11的值。SAVE_SN是一个宏,用于保存寄存器s0到s11到current_task_cx_ptr所指向的结构体中,对应的偏移量从16字节开始,每次增加8字节。

  • ld ra, 0(a1):加载下一个任务的返回地址到ra寄存器,这个地址来自next_task_cx_ptr所指向的结构体的起始位置。

  • .set n, 0 和 .rept 12 循环:这是另一个宏循环,用于从next_task_cx_ptr所指向的结构体中恢复寄存器s0到s11的值。LOAD_SN宏执行相应的加载操作。

  • ld sp, 8(a1):加载下一个任务的堆栈指针到sp寄存器,这个堆栈指针来自next_task_cx_ptr所指向的结构体的第二个位置。

  • ret:返回指令,它会跳转到ra寄存器中的地址,这里是下一个任务的继续执行点。

      // os/src/task/switch.rs将这段汇编代码中的全局符号__switch解释为了一个Rust函数,我们会调用该函数来完成切换功能而不是直接跳转到符号 __switch 的地址。因此在调用前后 Rust 编译器会自动帮助我们插入保存/恢复调用者保存寄存器的汇编代码
    

3.3管理多道程序

数据结构

// os/src/task/mod.rs

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

struct TaskManagerInner {
    tasks: [TaskControlBlock; MAX_APP_NUM],
    current_task: usize,
}
pub struct TaskControlBlock {
    /// The task status in it's lifecycle
    pub task_status: TaskStatus,
    /// The task context
    pub task_cx: TaskContext,
}

/// The status of a task
#[derive(Copy, Clone, PartialEq)]
pub enum TaskStatus {
    /// uninitialized
    UnInit,
    /// ready to run
    Ready,
    /// running
    Running,
    /// exited
    Exited,
}

将任务的状态和上下文记录在一起, 以便于任务调度

任务调度

找到当前任务的上下文(或者第一个任务运行时的初始化的上下文)和下一个任务的上下文, 将其作为参数传递给__switch函数:

  1. 第一次运行程序:
// os/src/task/mod.rs

impl TaskManager {
    fn run_first_task(&self) -> ! {
        let mut inner = self.inner.exclusive_access();
        let task0 = &mut inner.tasks[0];
        task0.task_status = TaskStatus::Running;
        let next_task_cx_ptr = &task0.task_cx as *const TaskContext;
        drop(inner);
        let mut _unused = TaskContext::zero_init();
        // before this, we should drop local variables that must be dropped manually
        unsafe {
            __switch(
                &mut _unused as *mut TaskContext,
                next_task_cx_ptr,
            );
        }
        panic!("unreachable in run_first_task!");
    }

pub fn run_first_task() {
    TASK_MANAGER.run_first_task();
}

此时我们没有执行任何应用,__switch前半部分的保存仅仅是在启动栈上保存了一些之后不会用到的数据

显式在启动栈上分配了一个_unused任务上下文并将其作为第一个参数传给__switch,保存一些寄存器之后启动栈栈顶的位置将会保存在此参数中,启动栈在应用开始运行后不会再用到,之后应用都在用户栈和内核栈之间切换

  1. 程序主动让出CPU或被抢占
// os/src/task/mod.rs

pub fn suspend_current_and_run_next() {
    mark_current_suspended();
    run_next_task();
}

 /// Change the status of current `Running` task into `Ready`.
 fn mark_current_suspended(&self) {
     let mut inner = self.inner.exclusive_access();
     let current = inner.current_task;
     inner.tasks[current].task_status = TaskStatus::Ready;
 }
  1. 程序运行结束
 // os/src/task/mod.rs
 pub fn exit_current_and_run_next() {
     mark_current_exited();
     run_next_task();
 }
 /// Change the status of current `Running` task into `Exited`.
 fn mark_current_exited(&self) {
     let mut inner = self.inner.exclusive_access();
     let current = inner.current_task;
     inner.tasks[current].task_status = TaskStatus::Exited;
 }
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 TaskContext;
        let next_task_cx_ptr = &inner.tasks[next].task_cx as *const TaskContext;
        drop(inner);
        // before this, we should drop local variables that must be dropped manually
        unsafe {
            __switch(current_task_cx_ptr, next_task_cx_ptr);
        }
        // go back to user mode
    } else {
        panic!("All applications completed!");
    }
}
  • find_next_task选择一个就绪的任务
  • 标记这个就绪的任务为TaskStatus::Running
  • 传递2个任务的上下文结构体给__switch完成上下文切换 image-24.png

3.4分时多任务系统与抢占式调度

一般将 时间片 (Time Slice) 作为应用连续执行时长的度量单位,每个时间片可能在毫秒量级。 简单起见,我们使用 时间片轮转算法 (RR, Round-Robin) 来对应用进行调度

计网入侵了噶人们(x

RISC-V中断机制

软件中断 (Software Interrupt):由软件控制发出的中断

时钟中断 (Timer Interrupt):由时钟电路发出的中断

外部中断 (External Interrupt):由外设发出的中断

时钟中断与计时器

时钟中断是操作系统实现抢占式多任务处理的核心机制。它允许操作系统定期中断当前执行的任务,使调度器能够决定是否继续执行当前任务或切换到其他任务。这种机制确保了:

公平的 CPU 时间分配:所有任务都能获得公平的 CPU 使用机会

系统响应性:即使某个任务执行长时间操作,系统仍能保持响应

真正的多任务:多个任务能够并发执行,提高系统利用率

时钟中断的工作原理

  1. 硬件时钟:硬件时钟(通常是一个定时器设备)被配置为在固定的时间间隔发出信号。这个时间间隔可以是毫秒级别,具体取决于操作系统的设计和配置。
  2. 中断信号:当硬件时钟达到预设的时间间隔时,它会向处理器发送一个中断信号。这个信号提示处理器当前正在执行的指令流应该被暂时中断
// os/src/timer.rs

use riscv::register::time;

pub fn get_time() -> usize {
    time::read()
}
// os/src/sbi.rs

const SBI_SET_TIMER: usize = 0;

pub fn set_timer(timer: usize) {
    sbi_call(SBI_SET_TIMER, timer, 0, 0);
}

// os/src/timer.rs

use crate::config::CLOCK_FREQ;
const TICKS_PER_SEC: usize = 100;

pub fn set_next_trigger() {
    set_timer(get_time() + CLOCK_FREQ / TICKS_PER_SEC);
}

sbi子模块有一个set_timer调用,用来设置mtimecmp的值,timer子模块set_next_trigger函数对set_timer进行了封装,它首先读取当前mtime的值,然后计算出10ms之内计数器的增量,再将mtimecmp设置为二者的和

  1. 中断服务例程(ISR):当处理器接收到中断信号后,会跳转执行特定的中断服务例程。这些 ISR 由操作系统提供,负责处理不同类型的中断。

     RISC-V 架构的特殊性:在 RISC-V 架构中,中断和异常通过统一的陷阱(Trap)机制进行处理。所有陷阱(包括中断和异常)都共享同一个入口点,这个入口地址存储在 stvec(Supervisor Trap Vector Base Address Register)寄存器中。
    
///os/src/trap/mod.rs
pub fn init() {
    extern "C" {
        fn __alltraps();
    }
    unsafe {
        stvec::write(__alltraps as usize, TrapMode::Direct);
    }
}

3.5抢占式调度

// os/src/trap/mod.rs

match scause.cause() {
    Trap::Interrupt(Interrupt::SupervisorTimer) => {
        set_next_trigger();
        suspend_current_and_run_next();
    }
}

当时钟中断(SupervisorTimer)发生时,陷阱处理程序会:

  1. 重置定时器以触发下一次中断

  2. 调用 suspend_current_and_run_next() 进行任务切换

  3. 保存当前任务上下文,切换到下一个就绪任务

image-25.png图源知乎

3.6 ch3练习

代码见github

  • 实现sys_trace
  • 新增syscall_count用于计数每个syscall_id对应syscall调用次数
  • 通过inc_current_syscall_count和get_current_syscall_count分别用于统计系统调用计数和获取系统调用计数,即可支持读、写、统计三种操作
  • 通过检查系统调用ID范围,避免越界

简答作业

RustSBI-QEMU Version 0.2.0-alpha.2

[kernel] PageFault in application, bad addr = 0x0, bad instruction = 0x804003c4, kernel killed it.
[kernel] IllegalInstruction in application, kernel killed it.
[kernel] IllegalInstruction in application, kernel killed it.

ch2b_bad_address.rs 用户程序尝试访问地址 0x0,该地址不在有效的用户地址空间内

ch2b_bad_instructions.rs 在用户态非法使用指令sret

ch2b_bad_register.rs 在U态通过csrr指令非法读取S态寄存器sstatus

RISC-V 特权级保护

  • 指令特权级检查:

    • U态程序无法执行sret, wfi, sfence.vma等S态指令

    • 尝试执行会触发非法指令异常

  • 寄存器访问保护:

    • U态无法访问S态CSR寄存器(sstatus, sie, stvec等)

    • CSR读写指令在错误特权级会触发异常

  • 内存访问保护:

    • 通过页表机制隔离用户空间和内核空间

    • 用户程序只能访问映射的用户地址空间

2-1.

刚进入 __restore 时,sp代表当前任务的内核栈顶

__restore 的两种使用情景:

任务切换后的恢复:当从调度器切换到新任务时,调用 __restore 来恢复该任务的用户态上下文

中断/异常处理返回:处理完陷阱后,恢复被中断的用户程序继续执行

2-2.

ld t0, 32*8(sp) # 内核栈 32*8(sp) 处存储了原 sstatus 寄存器的值, 将其读取到 t0
ld t1, 33*8(sp) # 内核栈 32*8(sp) 处存储了原 sepc 寄存器的值, 将其读取到 t1
ld t2, 2*8(sp) # 内核栈 32*8(sp) 处存储了原 sscratch 寄存器的值, 将其读取到 t2
csrw sstatus, t0 #  t0中原 sstatus 寄存器的值读取到 sstatus
csrw sepc, t1 #  t0中原 sepc 寄存器的值读取到 sepc
csrw sscratch, t2 #  t0中原 sscratch 寄存器的值读取到 sscratch

2-3.

x2 (sp):栈指针需要特殊处理,不能在这里直接恢复,因为当前还在使用内核栈

x4 (tp):线程指针通常在内核初始化后保持不变,不需要在每次陷阱返回时恢复

2-4.

L60

sp:指向用户栈指针

sscratch:指向内核栈指针(保存原来的 sp 值)

2-5.

sret检查sstatus.SPP位,如果为0表示返回用户态,将程序计数器设置为sepc的值,将特权级从S态降为U态,从sstatus.SPIE恢复sstatus.SIE

2-6.

L13

sp:保存了原sscratch中的内核栈指针
sscratch:保存了原sp中的用户栈栈指针

2-7.

ecall

荣誉准则

  1. 在完成本次实验的过程(含此前学习的过程)中,我曾分别与以下各位 就(与本次实验相关的)以下方面做过交流,还在代码中对应的位置以注释形式记录了具体的交流对象及内容:

  2. 此外,我也参考了 以下资料 ,还在代码中对应的位置以注释形式记录了具体的参考来源及内容:

  3. 我独立完成了本次实验除以上方面之外的所有工作,包括代码与文档。 我清楚地知道,从以上方面获得的信息在一定程度上降低了实验难度,可能会影响起评分。

  4. 我从未使用过他人的代码,不管是原封不动地复制,还是经过了某些等价转换。 我未曾也不会向他人(含此后各届同学)复制或公开我的实验代码,我有义务妥善保管好它们。 我提交至本实验的评测系统的代码,均无意于破坏或妨碍任何计算机系统的正常运转。 我清楚地知道,以上情况均为本课程纪律所禁止,若违反,对应的实验成绩将按“-100”分计。