rCore_Lab2

发布时间 2024-01-07 19:05:15作者: TLSN

lab2代码 https://github.com/TL-SN/rCore/tree/lab2

lab2 目的

本章我们的目标让泥盆纪“邓式鱼”操作系统能够感知多个应用程序的存在,并一个接一个地运行这些应用程序,当一个应用程序执行完毕后,会启动下一个应用程序,直到所有的应用程序都执行完毕。

image-20240103095023245

知识点

sret与eret与ecall

eret 代表一类执行环境返回指令,而 sret 特指从 Supervisor 模式的执行环境(即 OS 内核)返回的那条指令,也是本书中主要用到的指令。除了 sret 之外, mret 也属于执行环境返回指令,当从 Machine 模式的执行环境返回时使用, RustSBI 会用到这条指令。

ecall : 具有用户态到内核态的执行环境切换能力的函数调用指令;

sret : 表示从 S 模式返回到 U 模式

特权级

我们之前提到的引导加载程序会在加电后对整个系统进行初始化,它实际上是 SEE 功能的一部分,也就是说在 RISC-V 架构上的引导加载程序一般运行在 M 模式上

image-20240103100655814

image-20240103100749315

M 模式软件 SEE 和 S 模式的内核之间的接口被称为 监督模式二进制接口 (Supervisor Binary Interface, SBI),而内核和 U 模式的应用程序之间的接口被称为 应用程序二进制接口 (Application Binary Interface, ABI),当然它有一个更加通俗的名字—— 系统调用 (syscall, System Call) 。

进入S特权Trap的相关CSR

image-20240103203942655

sstatus : SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息

sepc : 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址

scause : 描述 Trap 的原因

stval : 给出 Trap 附加信息

stvec : 控制 Trap 处理代码的入口地址

Trap切换栈帧图

image-20240103230140539

rust知识点

use crate与use super

  1. use crate:::
    • 这种方式用于从 crate(箱)的根开始的绝对路径。
    • 当你想要引入位于 crate 根目录下的模块或项时,你会使用 use crate::
    • 它是明确地从当前 crate 的根目录开始引用模块或项。
    • 例如,如果你有一个叫做 foo 的模块在 crate 根目录下,你可以使用 use crate::foo; 来引入它。
  2. use super:::
    • 这种方式用于从当前模块的父模块开始的相对路径。
    • 当你想要引入当前模块的父模块中的内容时,你会使用 use super::
    • 它相当于在文件系统中的 ../,用于向上移动一级。
    • 例如,如果你在一个子模块中,并想要引入父模块中的 bar 函数,你可以使用 use super::bar;

rust---智能指针

通过记录所有者的数量,使一份数据被多个所有者同时持有,并在没有任何所有者时自动清理数据

引用与智能指针的不同

引用: 只借用数据

智能指针: 很多时候都拥有它所指向的数据

string与vec就是智能指针

智能指针的实现

image-20240103155315273

智能指针实例---Box

Box是最简单的智能指针

image-20240103155609164

image-20240103160554343

使用场景 :

1、在编译时,某类型的大小无法确定。但使用该类型时,上下文却需要知道它的确切大小。
2、当你有大量数据,想移交所有权,但需要确保在操作时数据不会被复制。
3、在编译时,某类型的大小无法确定。但使用该类型时,上下文却需要知道它的确切大小。当你有大量数据,想移交所有权,但需要确保在操作时数据不会被复制。

例子---使用Box实现链表

首先介绍Cons List

image-20240103160717567

直接用Cons List实现链表会报错,因为rust无法计算存储一个list需要多大的空间

image-20240103161219203

我们可以使用Box,用指针指向数据的方式来存储链表

Box是一个指针,Rust知道它需要多少空间

我们需要修改成这样:

image-20240103161508426

image-20240103161843281

这下可以运行了

Deref Trait

实现Deref Trait使我们可以自定义解引用运算符*的行为。通过实现Deref,智能指针可像常规引用一样来处理

image-20240103162228957

可以看到,let y = Box::new(x);相当于对x加了一层引用

相当于这样:

image-20240103162354879

下面我们手动实现一个具有Deref 特征的结构体MyBox

struct MyBox<T>(T);

impl<T> MyBox<T>{
    fn new(x: T) -> MyBox<T>{
        MyBox(x)
    }
}


fn main(){
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5,x);
    assert_eq!(5,*y);
}

这个是还未实现Deref 解引用特征的结构体,可以发现,*y操作是直接报错的

use std::ops::Deref;
struct MyBox<T>(T);

impl<T> MyBox<T>{
    fn new(x: T) -> MyBox<T>{
        MyBox(x)
    }
}
impl<T> Deref for MyBox<T> {
    type Target = T;
    fn deref(&self) -> &Self::Target {
        &self.0     // 元组的第0个元素的地址
    }
    
}


fn main(){
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5,x);
    assert_eq!(5,*y);   // *y <=> *(y.deref())
}


这里实现了Deref特征,可以发现程序正常运行

ps: 其实我觉得如果以c语言的角度理解这个解引用,这个Deref 特征的实现简直就是杀鸡用牛刀,本质上就是返回第零个元素的地址

Deref Coercion---隐式解引用转化

为函数和方法提供了一种便捷的特性

image-20240103171100781

image-20240103170502056

  1. 由于MyBox实现了Deref,故&MyBox => &String
  2. 由于String也实现了Deref,故&String => &str

即连续的隐式Deref转化

Drop Trait

可以让我们自定义当值将要离开作用域时发生的动作,类似于析构函数

image-20240103172822744

image-20240103172841488

显而易见,与析构函数极其相似

不能显式调用drop函数,但可以调用标准库的std::mem::drop函数来提前drop值

提前清理值

Rc<T>

reference couting,通过不可变引用使程序不同部分之间共享只读数据

引用计数智能指针

image-20240103173222128

使用场景

1、需要在heap上分配数据,这些数据被程序的多个部分读取(只读),但在编译时无法确定哪个部分最后使用完这些数据

2、单线程

例子

以图中方式建立链表

image-20240103173629854

image-20240103173616543

会报错,因为执行完 第13行代码后,a的所有权被转移到b,a自动销毁

image-20240103173718170

image-20240103173736322

用Rc来修改错误

image-20240103173837039

每次调用clone都会使Rc引用计数+1

再看一个例子

image-20240103174036751

运行:

image-20240103174052378

很明显,第16~19行代码一运行结束,rc计数就会减一

RefCell和内部可变性

上面讲的Rc是通过不可变引用使程序不同部分之间共享只读数据,而RefCell是在不可变环境中修改自身数据

image-20240103174710353

image-20240103174724956

RefCell也只能用于单线程

选择Box、Rc、RefCell的依据

image-20240103174831464

RefCell---可变的借用一个不可变的值

image-20240103174923238

上面的图片是无法可变的借用一个不可变的值的例子

适用场景

代码片段:

// 定义在外部库中的特征
pub trait Messenger {
    fn send(&self, msg: String);
}

// --------------------------
// 我们的代码中的数据结构和实现
struct MsgQueue {
    msg_cache: Vec<String>,
}

impl Messenger for MsgQueue {
    fn send(&self, msg: String) {
        self.msg_cache.push(msg)
    }
}

image-20240103181320397

修改方法,引入RefCell:

use std::cell::RefCell;
pub trait Messenger {
    fn send(&self, msg: String);
}

pub struct MsgQueue {
    msg_cache: RefCell<Vec<String>>,
}

impl Messenger for MsgQueue {
    fn send(&self, msg: String) {
        self.msg_cache.borrow_mut().push(msg)
    }
}

fn main() {
    let mq = MsgQueue {
        msg_cache: RefCell::new(Vec::new()),
    };
    mq.send("hello, world".to_string());
}

其实就是在一个不可变方法修改一个不可变值,默认这样是不可以的,但用RefCell包裹一层就绕开了这个规则。与 const T *p 修改T道理一样

image-20240103181732174

image-20240103181751427

Rc与RefCell结合使用,实现具有多重所有权的可变数据

#[derive(Debug)]
enum List{
    Cons(Rc<RefCell<i32>>,Rc<List>),
    Nil,
}

use crate::List::{Cons,Nil};
use std::rc::Rc;
use std::cell::RefCell;
fn main(){
    let value = Rc::new(RefCell::new(5));
    let a =  Rc::new(Cons(Rc::clone(&value),Rc::new(Nil)));
    let b = Cons(Rc::new(RefCell::new(6)),Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(10)),Rc::clone(&a));
    *value.borrow_mut()+=10;

    println!("a after = {:?}",a);
    println!("b after = {:?}",b);
    println!("c after = {:?}",c);
}

b , c就是利用Rc才能使用的a

RISC-V汇编指令集

汇编指示符

1、.align n 按2的n次幂字节对齐

2、.balign n 按n字节对齐

3、.global sym 声明sym未全局符号,其它文件可以访问

4、.string “str” 将字符串str放入内存

5、.byte b1,…,bn 在内存中连续存储n个单字节

.word w1,…,wn 在内存中连续存储n个字(4字节)

6、.text 代码段,之后跟的符号都在.text内

7、.data 数据段,之后跟的符号都在.data内

8、.bss 未初始化数据段,之后跟的符号都在.bss中

https://lgl88911.github.io/2021/02/28/RISC-V汇编快速入门/

常见寄存器

risc-v 有32个通用寄存器(简写 reg),标号为x0 - x31

image-20240103211300849

需要注意的是寄存器的别名

RISC-V指令

1、add => add t0, t1, t2 => t0 = t1 + t2

addi => addi t0, t1, -2 => t0 = t1 + (-2)

addi可以加负数,从而精简指令的个数

2、ld => ld t0, 0(t1) => t0 = memory[t1 + 0]

3、lw => lw t2, 20(t3) => t2 = memory[20 + t3]

ld、lw、lh、lb都是从内存中取值,而ld是从内存中取出64位数值,lw是取32位数值,lh是取16位,lb取8位

4、sd => sd t0, 0(t1) => memory[0+t1] = t0

5、sw => sw t0, 0(t1) => memory[0+t1] = t0

sd、sw、sh、sb的区别与ld...的相同

6、lwu => lwu t2, 20(t3) => t2 = memory[20 + t3]

lw 与lwu的区别在于,前者取出32位数值作符号扩展到64位,而后者做无符号扩展到64位

7、sll => sll t1, t2, t3 => t1 = t2 << t3

8、srl => srl t1, t2, t3 => t1 = t2 >> t3

t2右移t3位,做无符号扩展后赋值给t1

9、sra => sra t1, t2, t3 => t1 = t2 >> t3

t2右移t3位,做符号扩展后赋值给t1

10、beq => beq a1, a2, Lable => if(a1 == a2){goto Lable;}

11、jal => jal ra, Symbol => 跳转到Symbol中去, 并把ra设置成返回地址

12、jal => jal ra, 100 => 跳转到pc + 100 * 2的地方中去, 并把ra设置成返回地址

13、jalr => jalr ra, 40(t0) => 跳转到t0+40 的地方中去, 并把ra设置成返回地址

14、mv t0, t1 => t0 = t1

15、li t0, 100 => t0 = 100

16、j Label => 无条件跳到Label 处

17、ret => jal x0, ra

RISC-V中断的一些知识

参考: https://www.cnblogs.com/harrypotterjackson/p/17548837.html

lab2实现批处理的大体流程

在说明大体流程前先说明几个定义

1、AppManager

管理用户模式下的App应用的类

struct AppManager {
    num_app: usize,             //  记录app的数量
    current_app: usize,         // 记录表示当前执行的是第几个应用
    app_start: [usize; MAX_APP_NUM + 1],    // 所有app的起始地址
}

2、trap结构体

记录了trap前后保留的上下文

pub struct TrapContext {                // Trap 结构体
    /// general regs[0..31]             // 32个寄存器
    pub x: [usize; 32],
    /// CSR sstatus      
    pub sstatus: Sstatus,               // 当前所处的模式
    /// CSR sepc
    pub sepc: usize,                    // trap前的地址
}

3、所有用户程序执行的时候都在0x80400000的位置执行

4、所有用户程序均用 rust-objcopy --strip-all 转换为原始二进制格式,并被保存到了os的.data段

5、初始化0x105号CSR寄存器,RISC-V架构默认使用这个寄存器保存陷入地址

而且RISC-V的ecall跳转到的目标地址就是 0x105号的CSR寄存器 ,我们可以看到,RISC-V使用trap来实现系统调用

image-20240107135834392

6、所有用户程序均被编号,从0~4

7、trap_handler,用户执行trap指令并保存完毕上下文后会首先进入os的trap_handler中,由trap_handler决定下一步的工作

8、0x105 CSR寄存器指向的地址

其地址指向一处汇编代码存在的地址,该处汇编代码的功能就是切换为内核栈,保存用户态上下文(trap结构体)到内核栈上

image-20240107140322398

同时利用a0寄存器传参,调用trap_handler函数并把刚保存在trap结构体上的地址传递给trap_handler

image-20240107140608862

主要流程:

1、初始化0x105 CSR寄存器,把trap处理程序的地址写入该寄存器中

2、os做执行0号app的初始化工作

  1. 把app的二进制数据从.data段载入到0x80400000位置
  2. os恢复上下文(由于一开始在os中,所以0号app没有需要恢复的上下文,但会获得pc:0x80400000与用户栈空间的初始地址)

3、执行0号app,执行结束后调用exit()函数

  1. eixt内部执行了系统调用: sys_exit,用户执行系统调用进行trap,系统调用内部使用ecall 指令跳转到内核S态,目的地址来自0x105号CSR寄存器,该地址把用户的上下文(保留32个寄存器,trap前的地址,所处模式)保存在内核栈上(trap结构体)并用a0寄存器传参调用trap_handler
  2. trap_handler分发trap到sys_exit

4、执行完sys_exit后触发run_next_app函数

之后就循环 1~4,一直到执行完所有的app

存疑

image-20240107171635562

image-20240107171652234

这里SPP的字段被修改为CPU当前特权级,但SPP 不是用来保存之前的特权模式吗

image-20240107171919363

lab2对spp的处理感觉不是很完善,只写了退出s模式时候的spp处理,系统调用进入spp处理是只字不提

而且这些工作不是由CPU自主完成的嘛?

-------------------------------------------------------- 一些思考 ---------------------------------------------------------

我在实验的时候发现,如果把这里的SPP::user改成SPP::Supervisor会发生变化

image-20240107172947832

变化如下:

image-20240107172831692

qemu卡死在第0个app里,(因为SPP=1时执行sret相当于s模式下的trap)

我猜测:

  1. lab2文档的表述可能有些问题,SPP主要功能就是用来保存之前的特权模式的

  2. 当从 U 模式触发陷阱(如系统调用通过 ecall 指令)转换到 S 模式时,CPU 自动将 SPP 设置为 U 模式,以指示在处理完陷阱后应该返回到 U 模式。这种情况下,SPP 的值被设置为 0,表示 U 模式。

    而SPP的工作方式是这样的

  3. 当从 S 模式返回到 U 模式时(通常在陷阱或系统调用处理完毕后),CPU 会检查 sstatus 寄存器中的 SPP 字段。如果 SPP 被之前的操作设置为 U 模式(即 0),CPU 会将当前模式切换回 U 模式。

    在切换回 U 模式的过程中,SPP 字段的值通常不会被操作系统或 CPU 显式修改。一旦完成 sret 指令,SPP 字段可能被清零或保留(具体行为取决于 RISC-V 的具体实现),因为在 U 模式中,SPP 的值不再重要

系统调用的时候,CPU也会自主修改SPP,但不是说只能由CPU修改,操作系统也能修改

在lab2的实例中,s与u模式的相互切换过程中,SPP就一直为0的状态,我们看不到cpu为我们做的工作(本来就是0,CPU还要再把SPP设为0,所以我们看不到)

可以再后面的学习中留意一下这个问题

一些git命令

1、git init

执行指令进行初始化,会在原始文件夹中生成一个隐藏的文件夹.git

2、git add

执行指令将文件添加到本地仓库

git add .         //添加当前文件夹下的所有文件
$ git add **.cpp    //添加当前文件夹下的**.cpp这个文件

3、git commit -m "tlsn"

输入本次的提交说明,准备提交暂存区中的更改的已跟踪文件,单引号内为说明内容

 git commit -m "layout"  //引号中的内容为对该文件的描述

4、关联github仓库

git remote add origin https://github.com/TL-SN/rCore.git

5、 git push origin branch-name

git push origin lab2

如果我想新建分支并传入文件:

image-20240107185157927

感觉首先得关联github仓库,而且我觉得直接git push origin branch-name修改branch也可以?

lab3的时候试试