batch system introduction 这是hello kernel的后续
上一篇我们实现了打印hello kernel,现在我们继续迭代
先介绍一下历史
在计算机刚刚诞生的年代,很多事情并不像我们想象的那么简单
当时,程序被记录在打孔的卡片上,使用汇编语言甚至机器语言来编写(???)
而稀缺且昂贵的计算机由专业的管理员负责操作,就和我们在上一章所做的事情一样,他们手动将卡片输入计算机,等待程序运行结束或者终止程序的运行。最后,他们从计算机的输出端——也就是打印机中取出程序的输出并交给正在休息室等待的程序提交者…
实际上,这样做是一种对于珍贵的计算资源的浪费!
系统管理员在房间的各个地方跑来跑去,或是等待打印机的输出的这些时间段,计算机都并没有在工作,而人们希望计算机能够不间断的工作且专注于计算任务本身!
于是,批处理系统 (Batch System)应运而生!即本篇的主题~
其含义为:将多个程序打包到一起输入计算机,而当一个程序运行结束后,计算机会自动 加载下一个程序到内存并开始执行
当软件有了代替操作员的管理和操作能力后,便开始形成真正意义上的操作系统 了!
同时,应用程序总是难免会出现错误,如果一个程序的执行错误导致其它程序或者整个计算机系统都无法运行就太糟糕了!
人们希望一个应用程序的错误不要影响到其它应用程序,操作系统和整个计算机系统,这就需要操作系统能够终止出错的应用程序,转而运行下一个应用程序
这种保护 计算机系统不受有意或无意出错的程序破坏的机制被称为特权级 (Privilege)机制,它让应用程序运行在用户态 ,而操作系统运行在内核态 ,且实现用户态和内核态的隔离,这需要计算机软件和硬件的共同努力,也是我们本篇所要实现的内容…
基于综合考量,我们既要确保操作系统的安全,同时还需要确保应用程序能够得到操作系统的服务,即应用程序和操作系统还需要有交互 的手段,即系统调用,使得低特权级软件(一般应用)只能做高特权级软件(操作系统)允许它做的,且超出低特权级软件能力的功能必须寻求高特权级软件的帮助
具体通过这两条汇编指令实现(risc-v)
同时为了保证安全,我们至少要保证
应用程序不能访问任意的地址空间
应用程序不能执行某些可能破坏计算机系统的指令
这样,每层特权级的软件都只能做高特权级软件允许它做的(trap通过system call),且不会产生什么撼动高特权级软件的事情,一旦低特权级软件的要求超出了其能力范围,就必须寻求高特权级软件的帮助,否则就是一种异常 (exception)行为了
其它的异常则一般是在执行某一条指令的时候发生了某种错误(如除零 无效地址访问 无效指令等),或处理器认为处于当前特权级下执行的当前指令是高特权级指令 或 会访问不应该访问的高特权级的资源(可能危害系统)
碰到这些情况,就需要将控制转交给高特权级的软件(如操作系统)来处理
当错误/异常恢复后,则可重新回到低优先级软件去执行
如果不能恢复错误/异常,那高特权级软件可以杀死 和清除低特权级软件,避免破坏整个执行环境!
tips : 通用寄存器 : x0 ~ x31
x10~x17 : 对应 a0~a7
x1 :对应 ra
x2 : 对应 sp 控制状态寄存器(CSR Control and Status Register)
sstatus : SPP等字段给出Trap发生之前CPU处在哪个特权级(S/U)等信息
sepc : 当Trap是一个异常的时候,记录Trap发生之前执行的最后一条指令的地址
scause : 描述Trap的原因
stval : 给出Trap附加信息
stvec : 控制Trap处理代码的入口地址
实现应用程序 开始吧!
我们自顶向下(简单 -> 难 ? )
ps: 其实也不难 😐
我们先来实现应用程序 ~
项目中有:
hello_world :在屏幕上打印一行 Hello world from user mode program!
store_fault :访问一个非法的物理地址,测试批处理系统是否会被该错误影响
power :不断在计算操作和打印字符串操作之间进行特权级切换,测试trap是否正常
priv_inst : 用户态执行特权指令,测试
priv_csr : 用户态执行访问特权级CSR的指令,依旧测试
具体见下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 #![no_std] #![no_main] #[macro_use] extern crate user_lib;#[unsafe(no_mangle)] fn main () -> i32 { println! ("good morning good noon and good night my neighbor! have fun!" ); 0 }#![no_std] #![no_main] #[macro_use] extern crate user_lib;#[unsafe(no_mangle)] fn main () -> i32 { println! ("bitch1 : I will insert an invalid store operation to destroy you!" ); println! ("kernel : no problem! and I will kill you!" ); unsafe { core::ptr::null_mut::<u8 >().write_volatile (0 ); } 0 }#![no_std] #![no_main] #[macro_use] extern crate user_lib;const SIZE: usize = 10 ;const P: u32 = 3 ;const STEP: usize = 100000 ;const MOD: u32 = 10007 ;#[unsafe(no_mangle)] fn main () -> i32 { let mut pow = [0u32 ; SIZE]; let mut index : usize = 0 ; pow[index] = 1 ; for i in 1 ..=STEP { let last = pow[index]; index = (index + 1 ) % SIZE; pow[index] = last * P % MOD; if i % 10000 == 0 { println! ("{}^{}={}(MOD {})" , P, i, pow[index], MOD); } } println! ("I am so powerful! so easy job!" ); 0 }#![no_std] #![no_main] #[macro_use] extern crate user_lib;use core::arch::asm;#[unsafe(no_mangle)] fn main () -> i32 { println! ("bitch2 : I will execute privileged instruction in U Mode! hahaha!!!" ); println! ("kernel : fuck you bitch! I will kill you!" ); unsafe { asm!("sret" ); } 0 }#![no_std] #![no_main] #[macro_use] extern crate user_lib;use riscv::register::sstatus::{self , SPP};#[unsafe(no_mangle)] fn main () -> i32 { println! ("bitch3 : I will access privileged CSR in U Mode!" ); println! ("kernel : shit! so many bitches! are you kidding? go and die!" ); unsafe { sstatus::set_spp (SPP::User); } 0 }
我们还能够看到代码中尝试引入了外部库:
1 2 #[macro_use] extern crate user_lib;
这个外部库其实就是user目录下的lib.rs以及它引用的若干子模块
至于这个外部库为何叫user_lib而不叫lib.rs所在目录的名字user???
是因为在user/Cargo.toml中我们对于库的名字进行了设置name = "user_lib"
。。。
它作为bin目录下的源程序所依赖的用户库,等价于其它编程语言提供的标准库
在lib.rs中我们定义了用户库的入口点_start:
1 2 3 4 5 6 7 #[no_mangle] #[link_section = ".text.entry" ] pub extern "C" fn _start () -> ! { clear_bss (); exit (main ()); panic! ("unreachable after sys_exit!" ); }
都是上一篇的基础操作~
最关键的便是exit(main())产生的系统调用 ,后面见了…
我们还在lib.rs中看到了另一个main:
1 2 3 4 5 #[linkage = "weak" ] #[no_mangle] fn main () -> i32 { panic! ("Cannot find main!" ); }
使用rust的宏将其函数符号main标志为弱链接 ,这样在最后链接的时候,虽然在lib.rs和bin目录下的某个应用程序都有main符号,但由于lib.rs中的main 符号是弱链接,链接器会使用bin目录下的应用主逻辑作为main~
为了支持上述这种链接操作,我们需要在lib.rs的开头加入:
同理,我们要自己布置内存布局
如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 OUTPUT_ARCH(riscv) ENTRY(_start) BASE_ADDRESS = 0x80400000; SECTIONS { . = BASE_ADDRESS; .text : { } .rodata : { } .data : { } .bss : { start_bss = .; end_bss = .; } /DISCARD / : { } }
我们首先将程序的起始物理地址调整为0x80400000,三个应用程序都会被加载到这个物理地址上运行
然后将_start所在的.text.entry放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到0x80400000就已经进入了用户库的入口点,并会在初始化之后跳转到应用程序主逻辑
然后依旧提供了最终生成可执行文件的.bss段的起始和终止地址,方便clear_bss函数使用
我们依然得手动清空需要零初始化的.bss段
很遗憾到目前为止底层的批处理系统还没有这个能力,所以我们只能在用户库中完成😭
ps: 我又用上emoji了😁
接下来,我们实现系统调用 !
我们先约定如下两个系统调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 fn sys_write (fd: usize , buf: *const u8 , len: usize ) -> isize ;fn sys_exit (exit_code: usize ) -> !;
我们知道系统调用实际上是汇编指令级的二进制接口
因此这里给出的只是使用rust语言描述的API版本
在实际调用的时候,我们需要按照RISC-V调用规范(即ABI格式)在合适的寄存器中放置系统调用的参数,然后执行$ecall$指令触发Trap
在Trap回到U模式的应用程序代码之后,会从ecall 的下一条指令继续执行,同时我们能够按照调用规范在合适的寄存器中读取返回值
ps: 😃😀😄 ORW
约定寄存器a0~a6依次保存系统调用的参数,a0保存系统调用的返回值,寄存器a7用来传递syscall ID
由于这超出了rust语言的表达能力,我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和ecall指令的插入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 use core::arch::asm;fn syscall (id: usize , args: [usize ; 3 ]) -> isize { let mut ret : isize ; unsafe { asm!( "ecall" , inlateout ("x10" ) args[0 ] => ret, in ("x11" ) args[1 ], in ("x12" ) args[2 ], in ("x17" ) id ); } ret }
注意:由于a0既是第一个参数,又保存返回值,同时作为输入和输出,因此用inlateout
于是sys_write和sys_exit只需将syscall 进行包装
1 2 3 4 5 6 7 8 9 10 11 12 const SYSCALL_WRITE: usize = 64 ;const SYSCALL_EXIT: usize = 93 ;pub fn sys_write (fd: usize , buffer: &[u8 ]) -> isize { syscall (SYSCALL_WRITE, [fd, buffer.as_ptr () as usize , buffer.len ()]) }pub fn sys_exit (xstate: i32 ) -> isize { syscall (SYSCALL_EXIT, [xstate as usize , 0 , 0 ]) }
注意sys_write 使用一个&[u8]切片类型来描述缓冲区,这是一个胖指针 (Fat Pointer),里面既包含缓冲区的起始地址,还包含缓冲区的长度
我们可以分别通过 as_ptr 和 len 方法取出它们并独立地作为实际的系统调用参数!
我们将上述两个系统调用在用户库user_lib中进一步封装 ,从而更加接近在Linux等平台的实际系统调用接口~
1 2 3 4 5 use syscall::*;pub fn write (fd: usize , buf: &[u8 ]) -> isize { sys_write (fd, buf) }pub fn exit (exit_code: i32 ) -> isize { sys_exit (exit_code) }
同时,我们把console子模块中Stdout::write_str改成基于write 的实现,且传入的fd 参数设置为$1$,它代表标准输出,也就是输出到屏幕
1 2 3 4 5 6 7 8 9 const STDOUT: usize = 1 ;impl Write for Stdout { fn write_str (&mut self , s: &str ) -> fmt::Result { write (STDOUT, s.as_bytes ()); Ok (()) } }
exit接口则在用户库中的_start内使用,当应用程序主逻辑main返回之后,使用它退出应用程序并将返回值告知底层的批处理 系统,从而继续加载下一个应用程序~
太有意思了吧!☺️
实现批处理操作系统 现在我们可以开始着手实现批处理操作系统 了
batch system,启动!
在批处理操作系统中,每当一个应用执行完毕,我们都需要将下一个要执行的应用的代码和数据加载到内存,通过如下方式:
首先,我们将应用程序链接到内核
在os/src/main.rs中有:
1 global_asm!(include_str! ("link_app.S" ));
内容为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 # os/src/link_app.S .align 3 .section .data .global _num_app _num_app: .quad 5 .quad app_0_start .quad app_1_start .quad app_2_start .quad app_3_start .quad app_4_start .quad app_4_end .section .data .global app_0_start .global app_0_end app_0_start: .incbin "../user/target/riscv64gc-unknown-none-elf/release/00hello_world.bin" app_0_end: .section .data .global app_1_start .global app_1_end app_1_start: .incbin "../user/target/riscv64gc-unknown-none-elf/release/01store_fault.bin" app_1_end: .section .data .global app_2_start .global app_2_end app_2_start: .incbin "../user/target/riscv64gc-unknown-none-elf/release/02power.bin" app_2_end: .section .data .global app_3_start .global app_3_end app_3_start: .incbin "../user/target/riscv64gc-unknown-none-elf/release/03priv_inst.bin" app_3_end: .section .data .global app_4_start .global app_4_end app_4_start: .incbin "../user/target/riscv64gc-unknown-none-elf/release/04priv_csr.bin" app_4_end:
在构建操作系统(make run)时由由脚本os/build.rs自动生成,很好理解
build.rs 如下,可自行探索😌
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 use std::fs::{File, read_dir};use std::io::{Result , Write};fn main () { println! ("cargo:rerun-if-changed=../user/src/" ); println! ("cargo:rerun-if-changed={}" , TARGET_PATH); insert_app_data ().unwrap (); }static TARGET_PATH: &str = "../user/target/riscv64gc-unknown-none-elf/release/" ;fn insert_app_data () -> Result <()> { let mut f = File::create ("src/link_app.S" ).unwrap (); let mut apps : Vec <_> = read_dir ("../user/src/bin" ) .unwrap () .into_iter () .map (|dir_entry| { let mut name_with_ext = dir_entry.unwrap ().file_name ().into_string ().unwrap (); name_with_ext.drain (name_with_ext.find ('.' ).unwrap ()..name_with_ext.len ()); name_with_ext }) .collect (); apps.sort (); writeln! ( f, r#" .align 3 .section .data .global _num_app _num_app: .quad {}"# , apps.len () )?; for i in 0 ..apps.len () { writeln! (f, r#" .quad app_{}_start"# , i)?; } writeln! (f, r#" .quad app_{}_end"# , apps.len () - 1 )?; for (idx, app) in apps.iter ().enumerate () { println! ("app_{}: {}" , idx, app); writeln! ( f, r#" .section .data .global app_{0}_start .global app_{0}_end app_{0}_start: .incbin "{2}{1}.bin" app_{0}_end:"# , idx, app, TARGET_PATH )?; } Ok (()) }
为了找到并加载应用程序二进制码,我们需要实现一个应用管理器,保存应用数量和各自的位置信息,以及当前执行到第几个应用了
并根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行
应用管理器AppManager结构体定义如下:
1 2 3 4 5 6 7 struct AppManager { num_app: usize , current_app: usize , app_start: [usize ; MAX_APP_NUM + 1 ], }
定义很好理解,但接下来我们要解决一个很神秘的问题…
我们希望将AppManager 实例化为一个全局变量 ,使得任何函数都可以直接访问
但是!
里面的current_app字段表示当前执行的是第几个应用,它是一个可修改的变量,会在系统运行期间发生变化!
第一个想法是用static mut
显然这是unsafe 的
而我们要在编程中尽量避免使用unsafe,这样才能让编译器负责更多的安全性检查
因此,我们需要考虑如何在尽量避免触及unsafe的情况下仍能声明并使用可变的全局变量
Rust Tips : Rust 所有权模型和借用检查
这里简单介绍一下 Rust 的所有权模型 。它可以用一句话来概括:值(Value)在同一时间只能被绑定到一个变量(Variable)上。这里,值 指的是储存在内存中固定位置,且格式属于某种特定类型的数据;而变量就是我们在 Rust 代码中通过 let 声明的局部变量或者函数的参数等,变量的类型与值的类型相匹配。在这种情况下,我们称值的所有权 (Ownership)属于它被绑定到的变量,且变量可以作为访问/控制绑定到它上面的值的一个媒介。变量可以将它拥有的值的所有权转移给其他变量,或者当变量退出其作用域之后,它拥有的值也会被销毁,这意味着值占用的内存或其他资源会被回收
有些场景下,特别是在函数调用的时候,我们并不希望将当前上下文中的值的所有权转移到其他上下文中,因此类似于 C/C++ 中的按引用传参,Rust可以使用&或&mut后面加上值被绑定到的变量的名字来分别生成值的不可变引用 和可变引用 ,我们称这些引用分别$不可变/可变$借用(Borrow)它们引用的值。顾名思义,我们可以通过可变引用来修改它借用的值,但通过不可变引用则只能读取而不能修改。这些引用同样是需要被绑定到变量上的值,只是它们的类型是引用类型。在 Rust 中,引用类型的使用需要被编译器检查,但在数据表达上,和 C 的指针一样它只记录它借用的值所在的地址,因此在内存中它随平台不同仅会占据 4 字节或 8 字节空间
无论值的类型是否是引用类型,我们都定义值的生存 (Lifetime)为代码执行期间该值必须持续合法的代码区域集合,大概可以理解为该值在代码中的哪些地方被用到了,简单情况下,它可能等同于拥有它的变量的作用域,也有可能是从它被绑定开始直到它的拥有者变量最后一次出现或是它被解绑
当我们使用 & 和 &mut 来借用值的时候,我们编写的代码必须满足某些约束条件,不然无法通过编译:
这是为了 Rust 内存安全 而设计的重要约束条件。第一条很好理解,如果值的生存期未能完全覆盖借用它的引用的生存期,就会在某一时刻发生值已被销毁而我们仍然尝试通过引用来访问该值的情形。反过来说,显然当值合法时引用才有意义。最典型的例子是悬垂指针 (Dangling Pointer)问题,即我们尝试在一个函数中返回函数中声明的局部变量的引用,并在调用者函数中试图通过该引用访问已被销毁的局部变量,这会产生未定义行为并导致错误。第二,三条的主要目的则是为了避免通过多个引用对同一个值进行的读写操作产生冲突。例如,当对同一个值的读操作和写操作在时间上相互交错时(即不可变/可变引用的生存期部分重叠),读操作便有可能读到被修改到一半的值,通常这会是一个不合法的值从而导致程序无法正确运行。这可能是由于我们在编程上的疏忽,使得我们在读取一个值的时候忘记它目前正处在被修改到一半的状态,一个可能的例子是在 C++ 中正对容器进行迭代访问的时候修改了容器本身。也有可能被归结为别名 (Aliasing)问题,例如在 C 函数中有两个指针参数,如果它们指向相同的地址且编译器没有注意到这一点就进行过激的优化,将会使得编译结果偏离我们期望的语义
上述约束条件要求借用同一个值的不可变引用和不可变/可变引用的生存期相互隔离,从而能够解决这些问题。Rust编译器 会在编译时使用借用检查器 (Borrow Checker)检查这些约束条件是否被满足。其具体做法是尽可能精确的估计引用和值的生存期并将它们进行比较。随着 Rust 语言的愈发完善,其估计的精确度也会越来越高,使得程序员能够更容易通过借用检查。引用相关的借用检查发生在编译期,因此我们可以称其为编译期借用检查
相对的,对值的借用方式运行时可变的情况下,我们可以使用 Rust 内置的数据结构将借用检查推迟到运行时,这可以称为运行时借用检查 ,它的约束条件和编译期借用检查一致。当我们想要发起借用或终止借用时,只需调用对应数据结构提供的接口 即可。值的借用状态会占用一部分额外内存,运行时还会有额外的代码对借用合法性进行检查,这是$为满足借用方式的灵活性产生的必要开销$。当无法通过借用检查时,将会产生一个不可恢复错误,导致程序打印错误信息并立即退出。具体来说,我们通常使用RefCell 包裹可被借用的值,随后调用borrow和borrow_mut便可发起借用并获得一个对值的不可变/可变借用的标志,它们可以像引用一样使用。为了终止借用,我们只需手动销毁这些标志或者等待它们被自动销毁~
RefCell的详细用法请参考:
https://doc.rust-lang.org/stable/std/cell/struct.RefCell.html
如果单独使用 static 而去掉 mut 的话,我们可以声明一个初始化之后就不可变的全局变量 ,但是我们需要 AppManager 里面的内容在运行时发生变化
这涉及到 Rust 中的内部可变性 (Interior Mutability),也即在变量自身不可变或仅在不可变借用的情况下仍能修改绑定到变量上的值
我们可以通过用上面提到的 RefCell 来包裹AppManager,这样 RefCell 无需被声明为mut,同时被包裹的 AppManager 也能被修改
但是,我们能否将 RefCell 声明为一个全局变量呢?
试一试吧…
1 2 3 4 5 6 7 use std::cell::RefCell;static A: RefCell<i32 > = RefCell::new (3 );fn main () { *A.borrow_mut () = 4 ; println! ("{}" , A.borrow ()); }
结果是无法通过编译,报错:
1 2 3 4 5 6 7 8 9 10 error[E0277 ]: `RefCell<i32 >` cannot be shared between threads safely --> src/main.rs:2 :1 |2 | static A: RefCell<i32 > = RefCell: :ne w(3 ); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RefCell<i32 >` cannot be shared between threads safely | = help: the trait `Sync` is not implemented for `RefCell<i32 >` = note: shared static variables must have a type that implements `Sync` For more information about this error, try `rustc --explain E0277 `.
Rust编译器提示我们RefCell<i32>未被标记为Sync,因此 Rust 编译器认为它不能被安全的在线程 间共享,也就不能作为全局变量使用
但是,搞笑呢,我们这只是一个单线程程序,没有任何线程间共享数据的行为,为什么不能通过编译呢
原来,Rust对于并发安全 的检查较为粗糙,当声明一个全局变量的时候,编译器会$默认$程序员会在多线程上使用它,而并不会检查程序员是否真的这样做
如果一个变量实际上仅会在单线程上使用,那 Rust 会期待我们将变量分配在栈上作为局部变量 而不是全局变量
但是显然这不能满足我们的需求
怎么办呢?
我们在 RefCell 的基础上再$封装$一个UPSafeCell,从而允许我们在单核 上安全使用可变全局变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 pub struct UPSafeCell <T> { inner: RefCell<T>, }unsafe impl <T> Sync for UPSafeCell <T> {}impl <T> UPSafeCell<T> { pub unsafe fn new (value: T) -> Self { Self { inner: RefCell::new (value) } } pub fn exclusive_access (&self ) -> RefMut<'_ , T> { self .inner.borrow_mut () } }
这样当我们要访问数据时(无论读还是写),需要首先调用exclusive_access获得数据的可变借用标记,通过它可以完成数据的读写
在操作完成之后我们需要销毁这个标记,此后才能开始对该数据的下一次访问~
现在我们就能以尽量少 的unsafe code来初始化AppManager的全局实例APP_MANAGER了!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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, } })}; }
初始化的逻辑还是比较简单的
需要注意的是
一般情况下,全局变量必须在编译期设置一个初始值,但是有些全局变量依赖于运行期间才能得到的数据作为初始值,这导致这些全局变量需要在运行时发生变化,即需要重新设置初始值之后才能使用
因此我们使用lazy_static!宏以实现全局变量的运行时初始化功能
引入依赖
1 2 3 4 [dependencies] lazy_static = { version = "1.4.0" , features = ["spin_no_std" ] }
AppManager的方法中,有print_app_info/get_current_app/move_to_next_app,都是字面意思,见下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 pub fn print_app_info (&self ) { println! ("there are {} apps totally!!!" , self .num_app); for i in 0 ..self .num_app { println! ( "[kernel] app_{} range [{:#x}, {:#x})!" , i, self .app_start[i], self .app_start[i + 1 ] ); } }pub fn get_current_app (&self ) -> usize { self .current_app }pub fn move_to_next_app (&mut self ) { self .current_app += 1 ;
我们重点来看看load_app
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 unsafe fn load_app (&self , app_id: usize ) { if app_id >= self .num_app { panic! ("All applications completed!" ); } println! ("[kernel] Loading app_{}" , app_id); 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); asm!("fence.i" ); }
我们首先将一块内存清空,然后找到待加载应用二进制镜像的位置,并将它复制到正确的位置
它本质上是把数据从一块内存复制到另一块内存
在这一点上也体现了冯诺依曼计算机的代码即数据 的特征!
那这个神秘的fence.i起到什么作用呢?
我们知道缓存 是存储层级结构中提高访存速度的很重要一环
而 CPU 对物理内存所做的缓存又分成数据缓存 (d-cache)和指令缓存 (i-cache)两部分,分别在 CPU 访存和取指的时候使用
在取指的时候,对于一个指令地址,CPU会先去i-cache里面看一下它是否在某个已缓存的缓存行 内,如果在的话它就会直接从高速缓存中拿到指令而不是通过总线访问内存(性能 速度)
通常情况下,CPU会认为程序的代码段不会发生变化,因此i-cache是一种只读缓存
但在这里,OS将修改会被 CPU 取指的内存区域,这会使得 i-cache 中含有与内存中不一致的内容
因此,OS在这里必须使用取指屏障指令 fence.i
它的功能是保证在它之后的取指过程必须能够看到在它之前的所有对于取指内存区域的修改
这样才能保证 CPU 访问的应用代码是最新的而不是i-cache中过时的内容
至于硬件是如何实现fence.i这条指令的,这一点每个硬件的具体实现方式都可能不同,比如直接清空i-cache中所有内容或者标记其中某些内容不合法等等…
最后,我们在batch子模块对外暴露出如下接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 pub fn init () { print_app_info (); }pub fn print_app_info () { APP_MANAGER.exclusive_access ().print_app_info (); }pub fn run_next_app () -> ! { let mut app_manager = APP_MANAGER.exclusive_access (); let current_app = app_manager.get_current_app (); app_manager.load_app (current_app); app_manager.move_to_next_app (); drop (app_manager); unsafe extern "C" { unsafe fn __restore (cx_addr: usize ); } unsafe { __restore(KERNEL_STACK.push_context (TrapContext::app_init_context ( APP_BASE_ADDRESS, USER_STACK.get_sp (), )) as *const _ as usize ); } panic! ("Unreachable in batch::run_current_app!!!" ); }
实现特权级的切换 最后,我们来实现特权级的切换!
我们的应用程序在用户态特权级运行时,无法直接通过函数调用访问处于内核态特权级的批处理操作系统内核中的函数
但应用程序又需要得到操作系统提供的服务,所以应用程序与操作系统需要通过某种合作机制完成特权级之间的切换,使得用户态应用程序可以得到内核态操作系统函数的服务
我们至少要做到:
当启动应用程序的时候,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序
当应用程序发起系统调用(Trap)之后,需要到批处理操作系统中进行处理
当应用程序执行出错的时候,需要到批处理操作系统中杀死该应用并加载运行下一个应用
当应用程序执行结束的时候,需要到批处理操作系统中加载运行下一个应用(实际上就是通过系统调用sys_exit来实现的)
ps: 在introduction 中,我们已经介绍了寄存器 相关知识
在Trap触发的一瞬间,CPU就会切换到 S 特权级并跳转到stvec所指示的位置
但是在正式进入 S 特权级的 Trap 处理之前,我们必须保存原控制流的寄存器状态
这一般通过内核栈 来保存
至于为什么不直接用用户栈呢?
主要是为了安全性 :如果两个控制流(即应用程序的控制流和内核的控制流)使用同一个栈,在返回之后应用程序就能读到 Trap 控制流的历史信息,比如内核一些函数的地址,这样会带来安全隐患~
我们声明两个类型KernelStack和UserStack分别表示内核栈和用户栈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const USER_STACK_SIZE: usize = 4096 * 2 ;const KERNEL_STACK_SIZE: usize = 4096 * 2 ;#[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] };
二者的大小均为$8KiB$,且以全局变量的形式实例化在批处理操作系统的.bss段中
同时为两个类型实现get_sp方法来获取栈顶地址:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 impl KernelStack { fn get_sp (&self ) -> usize { self .data.as_ptr () as usize + KERNEL_STACK_SIZE } pub fn push_context (&self , cx: TrapContext) -> &'static mut TrapContext { let cx_ptr = (self .get_sp () - core::mem::size_of::<TrapContext>()) as *mut TrapContext; unsafe { *cx_ptr = cx; } unsafe { cx_ptr.as_mut ().unwrap () } } }impl UserStack { fn get_sp (&self ) -> usize { self .data.as_ptr () as usize + USER_STACK_SIZE } }
于是换栈 只需将sp寄存器的值修改为get_sp的返回值即可!
接下来我们定义Trap上下文:
1 2 3 4 5 6 7 8 #[repr(C)] pub struct TrapContext { pub x: [usize ; 32 ], pub sstatus: Sstatus, pub sepc: usize , }
重点保存通用寄存器x0~x31,sstatus与sepc
现在来具体实现 Trap 上下文保存和恢复的汇编代码
首先,我们需要修改stvec寄存器,使其指向正确的 Trap 处理入口点
1 2 3 4 5 6 7 8 9 10 global_asm!(include_str! ("trap.S" ));pub fn init () { extern "C" { fn __alltraps (); } unsafe { stvec::write (__alltraps as usize , TrapMode::Direct); } }
下面是__alltraps的实现,挺好理解的~
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 # os/src/trap/trap.S .macro SAVE_GP n sd x\n, \n*8(sp) .endm .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
tips: CSR 相关原子指令 :
RISC-V中读写CSR的指令是一类能不会被打断地完成多个读写操作的指令
这种不会被打断地完成多个操作的指令被称为原子指令 (Atomic Instruction),这里原子的含义是“不可分割的最小个体”,也就是说指令的多个操作要么都不完成,要么全部完成,而不会处于某种中间状态
另外,RISC-V架构中常规的数据处理和访存类指令只能操作通用寄存器而不能操作CSR
因此,当想要对CSR进行操作时,需要先使用读取CSR的指令将CSR读到一个通用寄存器中,而后操作该通用寄存器,最后再使用写入CSR的指令将该通用寄存器的值写入到CSR中!
当trap_handler返回之后则会从调用trap_handler的下一条指令开始执行,也就是下面的__restore!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 # os/src/trap/trap.S .macro LOAD_GP n ld x\n, \n*8(sp) .endm __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
依旧很好理解
接下来我们使用Rust实现trap_handler:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #[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, kernel killed it." ); run_next_app (); } Trap::Exception (Exception::IllegalInstruction) => { println! ("[kernel] IllegalInstruction in application, kernel killed it." ); run_next_app (); } _ => { panic! ("Unsupported trap {:?}, stval = {:#x}!" , scause.cause (), stval); } } cx }
需要引入依赖
1 2 3 4 [dependencies] riscv = { git = "https://github.com/rcore-os/riscv" , features = ["inline-asm" ] }
syscall函数并不会实际处理系统调用,而只是根据syscall ID分发到具体的处理函数:
1 2 3 4 5 6 7 8 9 pub fn syscall (syscall_id: usize , args: [usize ; 3 ]) -> isize { match syscall_id { SYSCALL_WRITE => sys_write (args[0 ], args[1 ] as *const u8 , args[2 ]), SYSCALL_EXIT => sys_exit (args[0 ] as i32 ), _ => panic! ("Unsupported syscall_id: {}" , syscall_id), } }
并将传进来的参数args转化成能够被具体的系统调用处理函数接受的类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const FD_STDOUT: usize = 1 ;pub fn sys_write (fd: usize , buf: *const u8 , len: usize ) -> isize { match fd { FD_STDOUT => { let slice = unsafe { core::slice::from_raw_parts (buf, len) }; let str = core::str ::from_utf8 (slice).unwrap (); print! ("{}" , str ); len as isize }, _ => { panic! ("Unsupported fd in sys_write!" ); } } }pub fn sys_exit (xstate: i32 ) -> ! { println! ("[kernel] Application exited with code {}" , xstate); run_next_app () }
最后 最后,我们将先前构建的操作系统与应用程序全都串联起来!
流程大致是:
启动QEMU(通电自检) -> 固件(firmware UEFI/BIOS) -> bootloader -> os -> user apps
下面是为启动应用程序而特殊构造的 Trap 上下文:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 impl TrapContext { pub fn set_sp (&mut self , sp: usize ) { self .x[2 ] = sp; } pub fn app_init_context (entry: usize , sp: usize ) -> Self { let mut sstatus = sstatus::read (); sstatus.set_spp (SPP::User); let mut cx = Self { x: [0 ; 32 ], sstatus, sepc: entry, }; cx.set_sp (sp); cx } }
我们在内核栈上压入上述Trap上下文,其sepc是应用程序入口地址0x80400000,其sp寄存器指向用户栈,其sstatus的SPP字段被设置为User
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 pub fn run_next_app () -> ! { let mut app_manager = APP_MANAGER.exclusive_access (); let current_app = app_manager.get_current_app (); unsafe { app_manager.load_app (current_app); } app_manager.move_to_next_app (); drop (app_manager); extern "C" { fn __restore (cx_addr: usize ); } unsafe { __restore(KERNEL_STACK.push_context ( TrapContext::app_init_context (APP_BASE_ADDRESS, USER_STACK.get_sp ()) ) as *const _ as usize ); } panic! ("Unreachable in batch::run_current_app!" ); }
push_context的返回值是内核栈压入Trap上下文之后的栈顶,它会被作为__restore的参数传入
这时我们就可以理解为何__restore函数的起始部分会有mov sp, a0了~
这使得在__restore函数中sp仍然可以指向内核栈的栈顶,这之后,就和执行一次普通的__restore函数调用一样了…
bootloader完成初始化后将控制权转交给我们的操作系统
下面是rust_main:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 #![deny(missing_docs)] #![deny(warnings)] #![no_main] #![no_std] use core::arch::global_asm;use log::*;#[macro_use] mod console;mod lang_items;mod logging;mod sbi;mod sync;pub mod syscall;pub mod batch;pub mod trap; global_asm!(include_str! ("entry.asm" )); global_asm!(include_str! ("link_app.S" ));fn clear_bss () { unsafe extern "C" { safe fn sbss (); safe fn ebss (); } unsafe { core::slice::from_raw_parts_mut (sbss as usize as *mut u8 , ebss as usize - sbss as usize ) .fill (0 ); } }#[unsafe(no_mangle)] pub fn rust_main () -> ! { unsafe extern "C" { safe fn stext (); safe fn etext (); safe fn srodata (); safe fn erodata (); safe fn sdata (); safe fn edata (); safe fn sbss (); safe fn ebss (); safe fn boot_stack_lower_bound (); safe fn boot_stack_top (); } clear_bss (); logging::init (); println! ("Hello roxy! I am kernel, your friend! Congratulations! You succeed in constructing a batch system!" ); trace!( "[kernel] .text [{:#x}, {:#x})" , stext as usize , etext as usize ); debug!( "[kernel] .rodata [{:#x}, {:#x})" , srodata as usize , erodata as usize ); info!( "[kernel] .data [{:#x}, {:#x})" , sdata as usize , edata as usize ); warn!( "[kernel] boot_stack top=bottom={:#x}, lower_bound={:#x}" , boot_stack_top as usize , boot_stack_lower_bound as usize ); error!("[kernel] .bss [{:#x}, {:#x})" , sbss as usize , ebss as usize ); trap::init (); batch::init (); batch::run_next_app (); }
操作系统依旧先清零.bss段,打印日志
随后初始化trap和batch
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 pub fn init () { unsafe extern "C" { safe fn __alltraps (); } unsafe { stvec::write (__alltraps as usize , TrapMode::Direct); } }pub fn init () { print_app_info (); }pub fn print_app_info () { APP_MANAGER.exclusive_access ().print_app_info (); } pub fn print_app_info (&self ) { println! ("there are {} apps totally!!!" , self .num_app); for i in 0 ..self .num_app { println! ( "[kernel] app_{} range [{:#x}, {:#x})!" , i, self .app_start[i], self .app_start[i + 1 ] ); } }
接下来便是上面的run_next_app
先exclusive_access,然后分别get_current_app,load_app,move_to_next_app,drop
需要注意的是,load_app仅完成将应用程序的代码加载到对应的内存地址,还并未真正执行
在__resotre的sret之后才会开始执行我们的第一个应用程序
随后便是在hello_world.rs和power.rs中测试系统调用的过程:
syscall -> ecall -> trap -> 保存寄存器 -> trap_handler(kernel) -> 恢复寄存器 -> 继续执行
即测试os能否正确处理trap
最后exit(main())系统调用以run_next_app
然后store_fault.rs,priv_inst.rs和priv_csr.rs则分别测试3种不同的异常 ,过程:
CPU异常 -> 自动trap_handler -> kill -> run_next_app
即测试os能否正确处理exception
这样应用程序便会依次执行了~
综上所述,我们客服重重困难,解决许多问题,终于实现了批处理操作系统 ,并使应用程序与os特权级别隔离,以处理trap和exception
这便是全过程了!
总览:
项目结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 ./os/src Rust 13 Files 372 Lines Assembly 2 Files 58 Lines ├── bootloader │ └── rustsbi-qemu.bin ├── LICENSE ├── os │ ├── build.rs (新增 : 生成 link_app.S 将应用作为一个数据段链接到内核) │ ├── Cargo.toml │ ├── Makefile (修改 : 构建内核之前先构建应用) │ └── src │ ├── batch.rs (新增 : 实现了一个简单的批处理系统) │ ├── console.rs │ ├── entry.asm │ ├── lang_items.rs │ ├── link_app.S (构建产物 由 os/build.rs 输出) │ ├── linker-qemu.ld │ ├── main.rs (修改 : 主函数中需要初始化 Trap 处理并加载和执行应用) │ ├── sbi.rs │ ├── sync (新增 : 同步子模块 sync 目前唯一功能是提供 UPSafeCell ) │ │ ├── mod.rs │ │ └── up.rs (包含 UPSafeCell 它可以帮助我们以更 Rust 的方式使用全局变量) │ ├── syscall (新增 : 系统调用子模块 syscall ) │ │ ├── fs.rs (包含文件 I/O 相关的 syscall ) │ │ ├── mod.rs (提供 syscall 方法根据 syscall ID 进行分发处理) │ │ └── process.rs (包含任务处理相关的 syscall) │ └── trap (新增 : Trap 相关子模块 trap ) │ ├── context.rs (包含 Trap 上下文 TrapContext ) │ ├── mod.rs (包含 Trap 处理入口 trap_handler ) │ └── trap.S (包含 Trap 上下文保存与恢复的汇编代码) ├── README.md ├── rust-toolchain └── user (新增 : 应用测例保存在 user 目录下) ├── Cargo.toml ├── Makefile └── src ├── bin (基于用户库 user_lib 开发的应用 每个应用放在一个源文件中) │ ├── 00 hello_world.rs │ ├── 01s tore_fault.rs │ ├── 02 power.rs │ ├── 03 priv_inst.rs │ └── 04 priv_csr.rs ├── console.rs ├── lang_items.rs ├── lib.rs (用户库 user_lib ) ├── linker.ld (应用的链接脚本) └── syscall.rs (包含 syscall 方法生成实际用于系统调用的汇编指令 | 各个具体的 syscall 都是通过 syscall 来实现的)
最后输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 [RustSBI output ] [kernel ] Hello, world! [kernel ] num_app = 5 [kernel ] app_0 [0x8020a038 , 0x8020af90 ) [kernel ] app_1 [0x8020af90 , 0x8020bf80 ) [kernel ] app_2 [0x8020bf80 , 0x8020d108 ) [kernel ] app_3 [0x8020d108 , 0x8020e0e0 ) [kernel ] app_4 [0x8020e0e0 , 0x8020f0b8 ) [kernel ] Loading app_0 Hello, world! [kernel ] Application exited with code 0 [kernel ] Loading app_1 Into Test store_fault, we will insert an invalid store operation... Kernel should kill this application! [kernel ] PageFault in application, kernel killed it. [kernel ] Loading app_23 ^10000 =5079 (MOD 10007 )3 ^20000 =8202 (MOD 10007 )3 ^30000 =8824 (MOD 10007 )3 ^40000 =5750 (MOD 10007 )3 ^50000 =3824 (MOD 10007 )3 ^60000 =8516 (MOD 10007 )3 ^70000 =2510 (MOD 10007 )3 ^80000 =9379 (MOD 10007 )3 ^90000 =2621 (MOD 10007 )3 ^100000 =2749 (MOD 10007 ) Test power OK! [kernel ] Application exited with code 0 [kernel ] Loading app_3 Try to execute privileged instruction in U Mode Kernel should kill this application! [kernel ] IllegalInstruction in application, kernel killed it. [kernel ] Loading app_4 Try to access privileged CSR in U Mode Kernel should kill this application! [kernel ] IllegalInstruction in application, kernel killed it. [kernel ] Panicked at src/batch.rs:58 All applications completed!
成功!
总结 ok啊终于结束了,自己理解的时候比较容易(真的吗?),但是想清晰地表达出来整个过程(maybe)还真是不容易啊
希望我能坚持下去吧…
最近pwn也摆了,后面再拾起吧…
马上开学了,大一下课程又多又满,不过依旧是”好好上课” 😁😁
最后祝你生活愉快,天天开心! 😎😎
感谢阅读… 😛
2026.3.22
坚持真难啊
又是一件我半途而废的事情
emm
😔
不过
开心就好呀哈哈哈