ch2

2025年10月18日 · 6188 字 · 13 分钟

2.1项目结构

#![feature(linkage)]    // 启用弱链接特性

#[linkage = "weak"]
#[no_mangle]
fn main() -> i32 {
    panic!("Cannot find main!");
}
虽然lib.rs 和 bin 目录下的某个应用程序中都有 main 符号, 但由于 lib.rs 中的 main 符号是弱链接, 链接器会使用 bin 目录下的函数作为 main 。 如果在 bin 目录下找不到任何 main ,那么编译也能通过,但会在运行时报错。

这样做的优点是允许可选地覆盖入口点,增加编译期灵活性,同时可以先编译通过,再逐步实现功能

2.2内存布局

2.3系统调用

2.4编译生成应用程序二进制码

2.5将应用程序链接到内核

link_app.S:

    .align 3          # 8字节对齐(2^3=8)
    .section .data
    .global _num_app   # 导出全局符号
_num_app: #应用程序描述符数组
    .quad 3       # 应用程序数量 = 3
    .quad app_0_start  # 应用程序0起始地址
    .quad app_1_start  # 应用程序1起始地址
    .quad app_2_start
    .quad app_3_start
    .quad app_4_start
    .quad app_5_start
    .quad app_6_start
    .quad app_6_end    # 最后一个应用程序的结束地址

    .section .data
    .global app_0_start
    .global app_0_end
app_0_start:
    .incbin "../user/build/bin/ch2b_bad_address.bin"
app_0_end:

    .section .data #三个数据段分别插入三个应用程序的二进制镜像
    .global app_1_start
    .global app_1_end
app_1_start:
    .incbin "../user/build/bin/ch2b_bad_instructions.bin"
app_1_end:

    .section .data #各自有一对全局符号 app_*_start, app_*_end 指示它们的开始和结束位置
    .global app_2_start
    .global app_2_end
app_2_start:
    .incbin "../user/build/bin/ch2b_bad_register.bin"
app_2_end:

    .section .data
    .global app_3_start
    .global app_3_end
app_3_start:
    .incbin "../user/build/bin/ch2b_hello_world.bin"
app_3_end:

    .section .data
    .global app_4_start
    .global app_4_end
app_4_start:
    .incbin "../user/build/bin/ch2b_power_3.bin"
app_4_end:

    .section .data
    .global app_5_start
    .global app_5_end
app_5_start:
    .incbin "../user/build/bin/ch2b_power_5.bin"
app_5_end:

    .section .data
    .global app_6_start
    .global app_6_end
app_6_start:
    .incbin "../user/build/bin/ch2b_power_7.bin"
app_6_end:
  • .quad用于定义一个或多个8字节大小的数据元素。每个.quad 表示一个64位(即8字节)的值。这个指令常被用于分配内存空间,并初始化数据段中的常量值。这段代码中,_num_app位置后的quad记录了这3个app的开始位置
  • .incbin直接在当前位置包含(嵌入)一个二进制文件的内容。还可以指定文件中的位置和长度, 例如:.incbin “data.bin”, 100, 50表示包含 data.bin 文件从第 100 个字节开始的接下来的 50 个字节

2.6 找到并加载应用程序二进制码

内核调度是按照顺序一个接一个地调用用户应用, 其主要数据结构为:

struct AppManager {
    num_app: usize,
    current_app: usize,
    app_start: [usize; MAX_APP_NUM + 1],
}

初始化 AppManager 的全局实例

lazy_static! {
    static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe {
        UPSafeCell::new({
            extern "C" {
                fn _num_app();
            }
            let num_app_ptr = _num_app as usize as *const usize;
            let num_app = num_app_ptr.read_volatile();
            let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
            let app_start_raw: &[usize] =
                core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1);
            app_start[..=num_app].copy_from_slice(app_start_raw);
            AppManager {
                num_app,
                current_app: 0,
                app_start,
            }
        })
    };
}

找到 link_app.S 中提供的符号 _num_app ,并从这里开始解析出应用数量以及各个应用的开头地址

封装了RefCell形成了UPSafeCell

UPSafeCell 既提供了内部可变性,又在单核情境下防止了内部对象被重复借用

RefCell< T > 提供了内部可变性,这意味着即使在 RefCell< T > 的引用是不可变的情况下,也可以改变它所包含的值,这违反了 Rust 的借用规则——即通常情况下,不能同时拥有可变和不可变引用,以及不可变引用不能用来改变值。

当需要在一个不可变的引用上修改数据时可以使用这种方法

不过绕过编译器检查还有运行时检查, RefCell< T > 使用运行时检查来确保借用规则,这是与编译时检查相对的(如通过 & 和 &mut 引用实现的)。从 RefCell< T > 中借用值时,如果违反了借用规则(例如,尝试进行两个可变借用或同时进行一个可变借用和任意数量的不可变借用),它会导致程序在运行时 panic

RefCell< T >例子:

use std::cell::RefCell;

fn main() {
    let value = RefCell::new(42);

    // 通过value.borrow_mut()来借用可变引用。
    let mut value_borrow_mut = value.borrow_mut();
    *value_borrow_mut += 1;

    // 这时,value已经被可变地借用,所以尝试再次借用会导致panic!
    // 下面的行如果取消注释,将会在运行时产生panic。
    // let value_borrow_mut2 = value.borrow_mut();

    println!("value: {}", value_borrow_mut);

    // 第一个可变引用离开作用域,所以可以再次借用。
    drop(value_borrow_mut);

    // 现在可以再次借用,因为之前的可变引用已经结束。
    let value_borrow = value.borrow();
    println!("value: {}", value_borrow);
}

lazy_static主要用于这样的需求: 想要创建一个全局变量, 但其初始化的值在编写代码时还不知道, 需要稍后初始化。在Rust中, 如果创建全局变量后再初始化会很繁琐, lazy_static简化了以上需求的操作难度

使用 lazy_static 创建的静态变量是线程安全的,并且保证只初始化一次。初始化发生在变量首次被访问的时候,并且如果初始化过程中发生了 panic,后续尝试访问该变量将会导致 panic。

最重要的调度函数是load_app, 其功能就是加载程序, 实际上并不是从文件系统加载, 而是从内核的某一个区段进行复制:

    unsafe fn load_app(&self, app_id: usize) {
        if app_id >= self.num_app {
            println!("All applications completed!");
            use crate::board::QEMUExit;
            crate::board::QEMU_EXIT_HANDLE.exit_success();
        }
        println!("[kernel] Loading app_{}", app_id);
        // clear app area
        core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *mut u8, APP_SIZE_LIMIT).fill(0);
        let app_src = core::slice::from_raw_parts(
            self.app_start[app_id] as *const u8,
            self.app_start[app_id + 1] - self.app_start[app_id],
        );
        let app_dst = core::slice::from_raw_parts_mut(APP_BASE_ADDRESS as *mut u8, app_src.len());
        app_dst.copy_from_slice(app_src);
        // Memory fence about fetching the instruction memory
        // It is guaranteed that a subsequent instruction fetch must
        // observes all previous writes to the instruction memory.
        // Therefore, fence.i must be executed after we have loaded
        // the code of the next app into the instruction memory.
        // See also: riscv non-priv spec chapter 3, 'Zifencei' extension.
        asm!("fence.i");
    }

所有的app都是在APP_BASE_ADDRESS地址处运行的, 每个app运行前都需要将其从其二进制代码的地址处复制到APP_BASE_ADDRESS地址处, 这也是load_app的核心工作

RISC-V 的 fence.i 指令使所有i-cache行无效,强制从内存重新取指

防止加载新应用时:

CPU通过数据通路将应用代码写入内存(更新d-cache)

但i-cache中可能还缓存着旧应用的指令

不清理CPU可能从i-cache读取到错误的指令

2.7特权级转换

image-19.png

RISC-V 定义了四个特权级别:

  1. 用户级别(User-Level or U-Mode):

    就是图中的App所在的级别, 用户级别是最低的特权级别,普通的应用程序在这个级别上运行。在这个级别上,程序不能直接访问硬件资源,如控制I/O和管理内存等。用户级别的代码需要通过系统调用(syscalls)与更高特权级别的软件交互来请求服务。而syscalls就是应用程序二进制接口, 图中的ABI。程序在用户级别也称为用户态

  2. 监督者级别(Supervisor-Level or S-Mode):

    监督者级别是操作系统内核通常运行的特权级别。它允许直接控制和管理硬件资源,包括内存管理单元(MMU)、中断处理等。大多数操作系统的内核,如Linux,会在S-Mode下运行。程序在用户级别也称为内核态。操作系统在态下其实也需要向更低一级的机器模式提出函数请求,这就是SBI所做的事情

  3. 机器级别(Machine-Level or M-Mode):

    机器级别是最高的特权级别,提供对RISC-V硬件的完全控制。它用于引导系统、处理最底层的中断和异常,以及配置系统的安全和保护设置。固件和监控程序,如我们使用的RustSBI(通常在M-Mode下运行。

  4. 超级用户级别(Hypervisor-Level or H-Mode):

    超级用户级别是为虚拟化环境设计的特权级别,在RISC-V体系结构中是一个可选的特权级别。它允许运行一个超级监控器(hypervisor),在单个物理硬件平台上虚拟化和管理多个独立的操作系统实例。rCore中不涉及这个级别

特权级转换

image-20.png

在RISC-V中,特权级切换通常在以下场景中发生:

  • 系统调用(System Calls):当用户程序需要操作系统提供的服务时,如文件操作、内存分配等,它会执行一个ecall指令来触发一个异常,导致处理器从用户模式(U-Mode)切换到监督者模式(S-Mode)或机器模式(M-Mode),这样操作系统可以安全地提供这些服务。
  • 中断(Interrupts):当外部设备需要处理器的注意时,它会发送一个中断信号。处理器响应中断信号也会导致特权级切换,通常是从较低的特权级别切换到机器模式(M-Mode),以便中断服务程序可以运行并处理中断。
  • 异常(Exceptions):当程序执行非法操作(如除以零、访问无权限的内存区域)时,或者出现硬件错误,就会发生异常。这将导致从当前特权级别切换到更高的特权级别,以便异常处理程序可以被执行来处理这些问题。
  • 特权级返回(Return from Trap):当中断或异常处理完成后,通过执行mret、sret或uret指令返回到发生中断或异常之前的特权级别。如果异常无法被正常处理, 则可能退出不会返回用户态, 而是在更高的特权级中尽显处理(关机蓝屏等就是这些更改特权级处理异常的方式)

异常控制流(区别与一般的函数控制)和特权级切换可以:

  • 保护系统和硬件不收错误的程序的损坏
  • 提供一层抽象, 便于开发

特权级切换指令

指令 描述
ecall 从用户态或监督者态触发一个环境调用异常,请求操作系统服务
ebreak 触发一个断点异常,用于调试
mret 从机器模式退出中断或异常处理程序并返回到之前的特权级别
sret 从监督者模式退出中断或异常处理程序并返回到之前的特权级别
uret 从用户模式退出中断或异常处理程序并返回到之前的特权级别

特权级切换相关寄存器

寄存器 描述
mstatus 保存机器模式的全局状态,包括全局中断使能位和特权级切换的状态
ustatus mstatus 的子集,用于保存用户模式的状态信息
mtvec 保存中断和异常处理例程的基地址(机器模式)
utvec 保存用户模式下中断和异常处理例程的基地址
mepc 保存发生异常时的程序计数器值(机器模式)
uepc 保存用户模式下发生异常时的程序计数器值
mcause 保存最后一次异常或中断的原因(机器模式)
ucause 保存用户模式下最后一次异常或中断的原因
sstatus mstatus 的子集,用于保存监督者模式的状态信息
scause 保存监督者模式下最后一次异常或中断的原因
sepc 保存监督者模式下发生异常时的程序计数器值
stval 给出 Trap 附加信息
stvec 保存监督者模式下中断和异常处理例程的基地址

riscv中的系统调用:

  1. 把系统调用的参数按照顺序放在a0~a6寄存器后
  2. 把系统调用号放在a7寄存器
  3. 调用ecall触发系统调用
  4. 在a0处获得系统调用的返回值

特权级切换:

系统调用会发生特权级切换, 特权级切换由于执行环境发生了变化, 要求我们在恢复原来的特权级时(例如从内核态返回用户态), 恢复执行环境的上下文。

  1. 用户态 ==> 内核态(ecall)
// 硬件自动设置
sstatus.SPP = current_privilege;  // U=0, S=1, M=3
sepc = pc + 4;                    // 返回地址
scause = trap_cause;              // 陷阱原因
stval = additional_info;          // 附加信息
pc = stvec;                       // 跳转到处理程序
privilege = S;                    // 切换到监督者模式

上述是硬件自动完成的, 如果有其他的寄存器由于陷入内核态后会被使用, 需要提前被保存, 通常会手动保存在栈里, 这些工作可能是stvec一开始就执行的工作

  1. 内核态 ==> 用户态 (sret)
// 硬件自动恢复
privilege = sstatus.SPP;  // 恢复之前的特权级
pc = sepc;                // 跳回到用户程序
sstatus.SPP = 0;          // 清除SPP字段
// 从内核栈恢复用户态上下文
for i in 0..32 {
    x[i] = trap_context.x[i];
}
sstatus = trap_context.sstatus;
sepc = trap_context.sepc;

2.8用户栈与内核栈

特权级切换时需要用栈来保存上下文信息, 因此需要定义内核栈和用户栈,两个栈以全局变量的形式实例化在批处理操作系统的 .bss 段中:

// os/src/batch.rs

#[repr(align(4096))]
struct KernelStack {
  data: [u8; KERNEL_STACK_SIZE],
}

#[repr(align(4096))]
struct UserStack {
  data: [u8; USER_STACK_SIZE],
}

static KERNEL_STACK: KernelStack = KernelStack {
  data: [0; KERNEL_STACK_SIZE],
};
static USER_STACK: UserStack = UserStack {
  data: [0; USER_STACK_SIZE],
};

2.9Trap上下文的保存与恢复

trap.S

.altmacro
.macro SAVE_GP n
    sd x\n, \n*8(sp)
.endm
.macro LOAD_GP n
    ld x\n, \n*8(sp)
.endm
    .section .text
    .globl __alltraps
    .globl __restore
    .align 2
__alltraps:
    csrrw sp, sscratch, sp
    # now sp->kernel stack, sscratch->user stack
    # allocate a TrapContext on kernel stack
    addi sp, sp, -34*8
    # save general-purpose registers
    sd x1, 1*8(sp)
    # skip sp(x2), we will save it later
    sd x3, 3*8(sp)
    # skip tp(x4), application does not use it
    # save x5~x31
    .set n, 5
    .rept 27
        SAVE_GP %n
        .set n, n+1
    .endr
    # we can use t0/t1/t2 freely, because they were saved on kernel stack
    csrr t0, sstatus
    csrr t1, sepc
    sd t0, 32*8(sp)
    sd t1, 33*8(sp)
    # read user stack from sscratch and save it on the kernel stack
    csrr t2, sscratch
    sd t2, 2*8(sp)
    # set input argument of trap_handler(cx: &mut TrapContext)
    mv a0, sp
    call trap_handler

__restore:
    # case1: start running app by __restore
    # case2: back to U after handling trap
    mv sp, a0
    # now sp->kernel stack(after allocated), sscratch->user stack
    # restore sstatus/sepc
    ld t0, 32*8(sp)
    ld t1, 33*8(sp)
    ld t2, 2*8(sp)
    csrw sstatus, t0
    csrw sepc, t1
    csrw sscratch, t2
    # restore general-purpuse registers except sp/tp
    ld x1, 1*8(sp)
    ld x3, 3*8(sp)
    .set n, 5
    .rept 27
        LOAD_GP %n
        .set n, n+1
    .endr
    # release TrapContext on kernel stack
    addi sp, sp, 34*8
    # now sp->kernel stack, sscratch->user stack
    csrrw sp, sscratch, sp
    sret
.altmacro 指令用于开启或关闭“替代宏语法模式”(alternate macro syntax mode)当altmacro 指令出现在文件中时,它会切换当前的宏处理模式。如果在 .altmacro 出现之前是标准宏模式,那么之后就会切换到替代宏模式;反之亦然

在替代宏模式下,可以使用 \() 来对参数进行求值,允许宏内部对参数进行算术运算。此外,还可以使用更复杂的字符串处理功能,比如连接字符串或使用条件表达式。

在这里,.altmacro 可能是用来确保宏定义中的 \n 参数可以正常地被替换和计算。在 .altmacro 模式下,宏 SAVE_GP 和 LOAD_GP 中的 \n 会在宏展开时被实际传递的参数值所替换,并计算出正确的偏移量。

例如,如果使用 SAVE_GP 2,替代宏模式会确保宏展开为 sd x2, 16(sp),这会将 x2 寄存器的内容保存到栈指针(sp)地址加上 16 字节处的内存位置(因为 2 * 8 = 16)。同理,LOAD_GP 宏则用于从相同的内存位置将数据加载回 x2 寄存器。

两个主要入口点:

  • __alltraps:处理从用户态陷入内核态(ecall/中断/异常)

  • __restore:从内核态返回用户态

  1. __alltraps:

栈切换

csrrw sp, sscratch, sp #交换 sp 和 sscratch
  • 进入前:sp = 用户栈,sscratch = 内核栈

  • 进入后:sp = 内核栈,sscratch = 用户栈

分配Trap上下文空间

addi sp, sp, -34*8  # 在内核栈上分配 34*8 字节的 TrapContext

保存通用寄存器

sd x1, 1*8(sp)     # 保存 ra
sd x3, 3*8(sp)     # 保存 gp
# 跳过 x2(sp), x4(tp)
.set n, 5
.rept 27           # 循环保存 x5-x31
    SAVE_GP %n
    .set n, n+1
.endr

因为 sp 会在后面单独保存,而 tp(线程指针)可能不被应用使用。

保护用户栈指针

csrr t0, sstatus
csrr t1, sepc #将 CSR sstatus 和 sepc 的值分别读到寄存器 t0  t1 中然后保存到内核栈对应的位置上
sd t0, 32*8(sp)
sd t1, 33*8(sp) 

调用Rust处理函数

mv a0, sp          # 参数:TrapContext 指针
call trap_handler  # 调用 Rust 陷阱处理程序
  1. __restore: 准备恢复
mv sp, a0  # a0 包含要恢复的 TrapContext 指针

恢复控制状态寄存器

ld t0, 32*8(sp)    # 加载 sstatus
ld t1, 33*8(sp)    # 加载 sepc  
ld t2, 2*8(sp)     # 加载用户栈指针
csrw sstatus, t0
csrw sepc, t1
csrw sscratch, t2  # 恢复用户栈到 sscratch

恢复通用寄存器

ld x1, 1*8(sp)     # 恢复 ra
ld x3, 3*8(sp)     # 恢复 gp
.set n, 5
.rept 27           # 循环恢复 x5-x31
    LOAD_GP %n
    .set n, n+1
.endr

清空栈空间并返回

addi sp, sp, 34*8  # 释放 TrapContext 空间
csrrw sp, sscratch, sp  # 切换回用户栈
sret               # 返回用户态

TrapContext内存布局

sp → +------------+
     |     x1     |  1*8  (ra)
     |     x2     |  2*8  (用户sp)
     |     x3     |  3*8  (gp)
     |    ...     |
     |    x31     |  31*8
     |  sstatus   |  32*8
     |   sepc     |  33*8
     +------------+

__restore 在两种情况下被使用,它既是异常处理完毕后恢复应用程序状态的入口点,也是应用程序第一次开始执行时的入口点。

在应用程序第一次开始执行时,__restore 这一步并不是在 “恢复” 任何先前的状态,因为此时还没有任何状态可以恢复。

相反,它是在初始化应用程序的执行环境, 具体而言需要再栈中压入构造的Trap Context。

在这种情况下,栈上加载的内容(如 sstatus、sepc 和 sscratch)是由操作系统预先设定好的,而不是由之前的应用程序执行状态保存的。

这些值会设置为允许应用程序在用户模式下执行的正确状态,并确保了程序计数器(sepc)指向应用程序的入口点。

2.10Trap分发与处理

// os/src/trap/mod.rs

#[no_mangle]
pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
    let scause = scause::read();
    let stval = stval::read();
    match scause.cause() {
        Trap::Exception(Exception::UserEnvCall) => {
            cx.sepc += 4;
            cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
        }
        Trap::Exception(Exception::StoreFault) |
        Trap::Exception(Exception::StorePageFault) => {
            println!("[kernel] PageFault in application, core dumped.");
            run_next_app();
        }
        Trap::Exception(Exception::IllegalInstruction) => {
            println!("[kernel] IllegalInstruction in application, core dumped.");
            run_next_app();
        }
        _ => {
            panic!("Unsupported trap {:?}, stval = {:#x}!", scause.cause(), stval);
        }
    }
    cx
}

根据scause分类处理, 目前实现了:

  • 系统调用陷入S态时执行系统调用
  • 非法指令和页错误直接运行下一个程序
  • 其余情况直接panic

image-21.png图源知乎

项目中使用过的宏

宏/属性 解释
#[repr(align(4096))] 设置结构体或枚举的内存对齐方式为 4096 字节。
#![feature(panic_info_message)] 允许使用实验性的 panic_info_message 功能,此功能允许访问 panic 信息中的消息内容。
#[macro_use] 允许在当前作用域中使用外部 crate 中定义的宏。
use core::arch::global_asm; 引入 global_asm! 宏,允许在 Rust 代码中嵌入全局汇编指令。
#[path = “boards/qemu.rs”] mod board; 指定模块文件的路径,这里是将模块文件定位到 boards/qemu.rs。
#[no_mangle] 禁用名称修饰,确保编译器生成的函数名称与在 Rust 中声明的名称相同。
#[inline(always)] 告诉编译器总是内联一个函数,无论编译器优化策略如何。
#[linkage = “weak”] 指定符号的链接强度为弱链接,允许在多个对象文件中定义相同的全局符号而不会导致链接错误,链接器将选择其中一个定义。
#[link_section = “.text.entry”] 指定函数或静态变量应该放置在特定的链接段中,在这个例子中是一个名为 .text.entry 的段。
#[repr(C)] 设置结构体或枚举的内存布局为 C 语言风格,这在与 C 代码交互时非常有用,因为它能保证字段在内存中的布局与 C 结构体相同。
#![feature(linkage)] 允许使用实验性的 linkage 属性,这个属性用于控制符号的链接方式。
#![feature(alloc_error_handler)] 允许自定义全局内存分配错误处理器。
#![no_std] 表明当前的程序或库不会链接到 Rust 的标准库 std,通常用于裸机或嵌入式编程中,其中资源受限,只能依赖核心库 core。
#![no_main] 禁用 Rust 默认的入口点,这通常用在裸机或操作系统开发中,因为在这些情况下开发者需要自定义入口点。
#[derive(…)] 编译时执行代码生成代码,让编译器为你的类型提供一些 Trait 的默认实现,实现了 Clone Trait 之后就可以调用 clone 函数完成拷贝;实现了 PartialEq Trait 之后就可以使用 == 运算符比较该类型的两个实例,从逻辑上说只有 两个相等的应用执行状态才会被判为相等,而事实上也确实如此。Copy 是一个标记 Trait,决定该类型在按值传参/赋值的时候采用移动语义还是复制语义