README.md 27.8 KB
Newer Older
欢乐城堡队's avatar
欢乐城堡队 committed
# OSKernel2023-XXXY
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
## 项目介绍
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
## 仓库文件阅读提示
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
+ codes:基于Tcore实现操作系统系统调用功能部分
+ codes/os:放置内核代码
+ codes/dependency:为稳定支持inline_asm指定的本地库以及其他依赖经过我们的研究测试可以支持llvm_asm!和asm!
+ codes/fat32 :构建文件系统
+ codes/user:用户程序
+ code:一个单道批处理内核
+ docs :相关文档以及相关插图
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
## 初步设计
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
实现了这样一个简单的,没有任何依赖的(除了运行在M态的RUST-SBI) 简易OS. 实现的过程如下:
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
1.编译的过程中指定目标平台riscv64gc-unknown-none-elf, 生成面相riscv64gc的没有os依赖的elf可执行程序,此时我们的println!就不会编译通过了,因为这个宏依赖标准库.所以在开发过程中,使用不依赖std的core库
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
2.接下来需要移除println, 自实现panic_handler, 自指定start入口(即修改main函数之前的运行时初始化部分), 实现exit的sys call, 通过这几部分就能够编译出一个无依赖的可执行RV64-elf程序
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
3.基于以上的思路,我们再添加对于shutdown的sysCall, 然后将链接时的内存布局调整为Bootloader_RustSBI所要求的格式(如设置栈,清空.bss,设置_start入口),这样我们就能够基于模拟器qemu将这个简易的内核启动起来了
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
#### 移除标准依赖库
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
println! 宏所在的 Rust 标准库 std 需要通过系统调用获得操作系统的服务,而如果要构建运行在裸机上的操作系统,就不能再依赖标准库了。
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
在 main.rs 的开头加上一行 #![no_std] 来告诉 Rust 编译器不使用 Rust 标准库 std ,转而使用核心库 core(core库不需要操作系统的支持)。同时记得注释掉之前使用的println!宏
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
事实上 start 语义项代表了标准库 std 在执行应用程序之前需要进行的一些初始化工作。由于我们禁用了标准库,编译器也就找不到这项功能的实现了。
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
最简单的解决方案就是压根不让编译器使用这项功能。我们在 main.rs 的开头加入设置 #![no_main] 告诉编译器我们没有一般意义上的 main 函数,并将原来的 main 函数删除,压根不让编译器使用这项功能。在失去了 main 函数的情况下,编译器也就不需要完成所谓的初始化工作。
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
    // os/src/main.rs
    #![no_main]
    #![no_std]
    mod lang_items;
    // ... other code
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
#### 使用panic函数对接panic!宏
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
    // os/src/lang_items.rs
    use core::panic::PanicInfo;
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
    #[panic_handler]
    fn panic(_info: &PanicInfo) -> ! {
        loop {}
    }
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
#### 调整内核的内存布局
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
链接器默认的内存布局并不能实现与Qemu的正确对接,我们通过链接脚本 (Linker Script) 调整链接器的行为,使得最终生成的可执行文件的内存布局符合预期。修改 Cargo 的配置文件来使用我们自己设计的链接脚本 os/src/linker.ld 而非使用默认的内存布局:
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
    # os/.cargo/config
    [build]
    target = "riscv64gc-unknown-none-elf"
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
    [target.riscv64gc-unknown-none-elf]
    rustflags = [
        "-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
    ]
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
此后可以生成内核可执行文件,切换到 os 目录下并进行以下操作:
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
    $ cargo build --release
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
#### 函数调用与栈
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
在调用子函数之前,需要在物理内存中的一个区域保存 (Save) 函数调用上下文中的寄存器;而在函数执行完毕后,我们会从内存中同样的区域读取并恢复 (Restore) 函数调用上下文中的寄存器。实际上,这一工作是由子函数的调用者和被调用者(也就是子函数自身)合作完成。函数调用上下文中的寄存器被分为如下两类:
欢乐城堡队's avatar
欢乐城堡队 committed

欢乐城堡队's avatar
欢乐城堡队 committed
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557
- 被调用者保存 (Callee-Saved) 寄存器 :被调用的函数可能会覆盖这些寄存器,需要被调用的函数来保存的寄存器,即由被调用的函数来保证在调用前后,这些寄存器保持不变;
- 调用者保存 (Caller-Saved) 寄存器 :被调用的函数可能会覆盖这些寄存器,需要发起调用的函数来保存的寄存器,即由发起调用的函数来保证在调用前后,这些寄存器保持不变。

函数调用上下文由调用者和被调用者分别保存,其具体过程分别如下:

- 调用函数:首先保存不希望在函数调用过程中发生变化的 调用者保存寄存器 ,然后通过 jal/jalr 指令调用子函数,返回之后恢复这些寄存器。
- 被调用函数:在被调用函数的起始,先保存函数执行过程中被用到的 被调用者保存寄存器 ,然后执行函数,最后在函数退出之前恢复这些寄存器。

调用规范:

|寄存器组 | 保存者|功能 |
| ------ | ------ | ------ |
|a0 ~ a7 ( x10 ~ x17 ) |调用者保存 |用来传递输入参数。其中的a0和a1还用来保存返回值。|
|t0 ~ t6 ( x5 ~ x7,x28 ~ x31 )|调用者保存|作为临时寄存器使用,在被调函数中可以随意使用无需保存。|
|s0 ~ s11 ( x8 ~ x9,x18 ~ x27 )|被调用者保存|作为临时寄存器使用,被调函数保存后才能在被调函数中使用。|

#### 分配并使用启动栈

在 entry.asm 中分配启动栈空间,并在控制权被转交给 Rust 入口之前将栈指针 sp 设置为栈顶的位置。

    # os/src/entry.asm
        .section .text.entry
        .globl _start
    _start:
        la sp, boot_stack_top
        call rust_main

        .section .bss.stack
        .globl boot_stack
    boot_stack:
        .space 4096 * 16
        .globl boot_stack_top
    boot_stack_top:

用更高地址的符号 boot_stack_top 来标识栈顶的位置,而用更低地址的符号 boot_stack 来标识栈底的位置,它们都被设置为全局符号供其他目标文件使用。第 8 行 .section .bss.stack 可以看到我们将这块空间放置在一个名为 .bss.stack 的段中,在链接脚本 linker.ld 中可以看到 .bss.stack 段最终会被汇集到 .bss 段中:

    .bss : {
        *(.bss.stack)
        sbss = .;
        *(.bss .bss.*)
        *(.sbss .sbss.*)
    }
    ebss = .;

顺便完成对 .bss 段的清零。在使用任何被分配到 .bss 段的全局变量之前我们需要确保 .bss 段已被清零。我们就在 rust_main 的开头完成这一工作,由于控制权已经被转交给 Rust ,后续就可以使用Rust来实现功能:

    // os/src/main.rs
    #[no_mangle]
    pub fn rust_main() -> ! {
        clear_bss();
        loop {}
    }

    fn clear_bss() {
        extern "C" {
            fn sbss();
            fn ebss();
        }
        (sbss as usize..ebss as usize).for_each(|a| {
            unsafe { (a as *mut u8).write_volatile(0) }
        });
    }

在函数 clear_bss 中,会尝试从其他地方找到全局符号 sbss 和 ebss ,它们由链接脚本 linker.ld 给出,并分别指出需要被清零的 .bss 段的起始和终止地址。接下来只需遍历该地址区间并逐字节进行清零即可。

#### 使用 RustSBI 提供的服务

在内核运行时响应内核的请求为内核提供服务。当内核发出请求时,计算机会转由 RustSBI 控制来响应内核的请求,待请求处理完毕后,计算机控制权会被交还给内核。从内存布局的角度来思考,每一层执行环境(或称软件栈)都对应到内存中的一段代码和数据,这里的控制权转移指的是 CPU 从执行一层软件的代码到执行另一层软件的代码的过程。这个过程和函数调用比较像,但是内核无法通过函数调用来请求 RustSBI 提供的服务,这是因为内核并没有和 RustSBI 链接到一起,事实上,内核需要通过另一种复杂的方式来 “调用” RustSBI 的服务:

    // os/src/main.rs
    mod sbi;

    // os/src/sbi.rs
    use core::arch::asm;
    #[inline(always)]
    fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
        let mut ret;
        unsafe {
            asm!(
                "ecall",
                inlateout("x10") arg0 => ret,
                in("x11") arg1,
                in("x12") arg2,
                in("x17") which,
            );
        }
        ret
    } 

我们将内核与 RustSBI 通信的相关功能实现在子模块 sbi 中,因此我们需要在 main.rs 中加入 mod sbi 将该子模块加入我们的项目。


## 特权级机制

简单来讲,RISC-V一般共分为3个特权级(MSU)(一共定义了4种特权级(MHSU)除了M模式必须存在外,其他模式可以不存在),通过ecall & sret完成高特权级和低特权级的切换,通过 指令 & 内存 & 寄存器 三者共同的特权级机制完成对系统的保护。

| 级别 | 编码 |名称|
| ------ | ------ | ------ |
|0|  00  |用户/应用模式 (U, User/Application)|
|1| 01 |监督模式 (S, Supervisor)|
|2| 10 |虚拟监督模式 (H, Hypervisor)|
|3| 11 |机器模式 (M, Machine)|

当CPU从U态trap到S态的时候,会完成以下动作(具体的实现还是需要看trap.S,基本思路是依次保存各个可能在处理trap的过程中可能改变的寄存器,在内存中手动组装出trap_handler的参数,这样就能够在rust中实现trap的处理了):

- sstatus 的 SPP 字段会被修改为 CPU 当前的特权级(U/S)。
- sepc 会被修改为 Trap 回来之后默认会执行的下一条指令的地址。当 Trap 是一个异常的时候,它实际会被修改成 Trap 之前执行的最后一条指令的地址。
- scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
- CPU 会跳转到 stvec(CSR,目前只用到direct的模式,完成从id到具体处理函数的映射)所设置的 Trap 处理入口地址,并将当前特权级设置为 S,然后开始向下执行。

当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret 来完成,这一条指令具体完成以下功能:

- CPU 会将当前的特权级按照 sstatus 的 SPP 字段设置为 U 或者 S
- CPU 会跳转到 sepc 寄存器指向的那条指令,然后开始向下执行

在 RISC-V 中,会有两类属于高特权级 S 模式的特权指令:

- 指令本身属于高特权级的指令,如 sret 指令(表示从 S 模式返回到 U 模式)。
- 指令访问了 S模式特权级下才能访问的寄存器 或内存,如表示S模式系统状态的 控制状态寄存器 sstatus 等。

| 指令 | 含义 |
| ------ | ------ |
|  sret  |    从 S 模式返回 U 模式:在 U 模式下执行会产生非法指令异常    |
|   wfi  |处理器在空闲时进入低功耗状态等待中断:在 U 模式下执行会产生非法指令异常|
|sfence.vma|刷新 TLB 缓存:在 U 模式下执行会产生非法指令异常|
|访问 S 模式 CSR 的指令|通过访问 sepc/stvec/scause/sscartch/stval/sstatus/satp等CSR 来改变系统状态:在 U 模式下执行会产生非法指令异常|


## 多道程序加载运行的实现

要实现多道应用程序的顺序执行, 首先就要实现S态的多个ABI, 在Ch2中首先实现了sys_write sys_exit(与基于RISC-V的linux发行版的接口完全一致,这样就可以利用qemu运行用户态程序测试), 保证用户态可以向串口(即屏幕)输出字符,并且可以在任务完成时退出

实现了系统调用之后, 将这些ABI封装成user_lib, 应用程序use这个库之后就可以利用ABI编写程序了, 多个用户态应用在编译为可执行elf文件之后,通过objcopy提取出可执行二进制部分,这三部分将直接被链接进内核. 链接的方式有静态和动态,
静态编码:通过一定的编程技巧,把应用程序代码和批处理操作系统代码“绑定”在一起。
动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。
在实现了文件系统(Ch7)之后就不会有这么重的耦合了

rust中的ABI调用没法直接通过函数实现,只能是通过llvm_asm的方式进行调用

在实现了用户态的各个程序之后,此时os内核的部分其实还未实现,这时可以使用qemu-riscv64(支持模拟riscv64的内核)来先行测试

#### 实现应用程序
应用程序的设计实现要点是:

- 应用程序的内存布局
- 应用程序发出的系统调用

应用程序、用户库(包括入口函数、初始化函数、I/O 函数和系统调用接口等多个 rs 文件组成)放在 user 目录下:


- user/src/bin/*.rs :各个应用程序
- user/src/*.rs :用户库(包括入口函数、初始化函数、I/O 函数和系统调用接口等)
- user/src/linker.ld :应用程序的内存布局说明。

在 lib.rs 中我们定义了用户库的入口点 _start :

    #[no_mangle]
    #[link_section = ".text.entry"]
    pub extern "C" fn _start() -> ! {
        clear_bss();
        exit(main());
        panic!("unreachable after sys_exit!");
    }

##### 系统调用

在子模块 syscall 中,应用程序通过 ecall 调用批处理系统提供的接口,由于应用程序运行在用户态(即 U 模式), ecall 指令会触发 名为 Environment call from U-mode 的异常,并 Trap 进入 S 模式执行批处理系统针对这个异常特别提供的服务代码。

应用程序和批处理系统之间按照 API 的结构,约定如下两个系统调用:

    /// 功能:将内存中缓冲区中的数据写入文件。
    /// 参数:`fd` 表示待写入文件的文件描述符;
    ///      `buf` 表示内存中缓冲区的起始地址;
    ///      `len` 表示内存中缓冲区的长度。
    /// 返回值:返回成功写入的长度。
    /// syscall ID:64
    fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize;

    /// 功能:退出应用程序并将返回值告知批处理系统。
    /// 参数:`xstate` 表示应用程序的返回值。
    /// 返回值:该系统调用不应该返回。
    /// syscall ID:93
    fn sys_exit(xstate: usize) -> !;

在代码中使用内嵌汇编来完成参数/返回值绑定和 ecall 指令的插入:

    // user/src/syscall.rs
    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
    }

第 3 行,我们将所有的系统调用都封装成 syscall 函数,可以看到它支持传入 syscall ID 和 3 个参数。syscall 中使用从第 5 行开始的 asm! 宏嵌入 ecall 指令来触发系统调用。

    // os/src/main.rs
    mod sbi;

    // os/src/sbi.rs
    use core::arch::asm;
    #[inline(always)]
    fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
        let mut ret;
        unsafe {
            asm!(
                "ecall",
                inlateout("x10") arg0 => ret,
                in("x11") arg1,
                in("x12") arg2,
                in("x17") which,
            );
        }
        ret
    }

于是 sys_write 和 sys_exit 只需将 syscall 进行包装:

    // user/src/syscall.rs

    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 等平台的实际系统调用接口:

    // user/src/lib.rs
    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,这样,应用程序的 println! 宏借助系统调用变得可用了。 参考下面的代码片段:

    // user/src/console.rs
    const STDOUT: usize = 1;

    impl Write for Stdout {
        fn write_str(&mut self, s: &str) -> fmt::Result {
            write(STDOUT, s.as_bytes());
            Ok(())
        }
    }

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

只需要在 user 目录下 make build 即可:

1. 对于 src/bin 下的每个应用程序,在 target/riscv64gc-unknown-none-elf/release 目录下生成一个同名的 ELF 可执行文件;
2. 使用 objcopy 二进制工具将上一步中生成的 ELF 文件删除所有 ELF header 和符号得到 .bin 后缀的纯二进制镜像文件。它们将被链接进内核并由内核在合适的时机加载到内存。





## 批处理的实现

需要实现的就是一个批量任务管理器,主要完成

- 保存应用数量和各自的位置信息,以及当前执行到第几个应用了

    通过读link_app.S里的内容来确定app的数量和位置,并保存到AppManager中供调度时使用

- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行

    load一个app的步骤大概是先清空以0x80400000为起点的一块内存,然后将app的二进制部分copy到此处执行.
    因为目前还没有实现分时的功能,所以每个app都是一直运行到结束调度器才会将下一个app加载
    每次新加载的时候数据缓存 (d-cache) 和 指令缓存 (i-cache) 都要清空

应用放置采用 “静态绑定” 的方式,而操作系统加载应用则采用 “动态加载” 的方式:

- 静态绑定:通过一定的编程技巧,把多个应用程序代码和批处理操作系统代码“绑定”在一起。
- 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到每个应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。

#### 将应用程序链接到内核

在 os/src/main.rs 中:

    global_asm!(include_str!("link_app.S"));

使用 make run 让系统运行的过程中,这个汇编代码 link_app.S 就生成了。

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

我们在 os 的 batch 子模块中实现一个应用管理器,它的主要功能是:

- 保存应用数量和各自的位置信息,以及当前执行到第几个应用了。
- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行。

应用管理器 AppManager 结构体定义如下:

    // os/src/batch.rs

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

将 AppManager 实例化为一个全局变量,使得任何函数都可以直接访问。

在 RefCell 的基础上再封装一个 UPSafeCell ,它名字的含义是:允许在单核上安全使用可变全局变量。

    // os/src/sync/up.rs

    pub struct UPSafeCell<T> {
        /// inner data
        inner: RefCell<T>,
    }

    unsafe impl<T> Sync for UPSafeCell<T> {}

    impl<T> UPSafeCell<T> {
        /// User is responsible to guarantee that inner struct is only used in
        /// uniprocessor.
        pub unsafe fn new(value: T) -> Self {
            Self { inner: RefCell::new(value) }
        }
        /// Exclusive access inner data in UPSafeCell. Panic if the data has been borrowed.
        pub fn exclusive_access(&self) -> RefMut<'_, T> {
            self.inner.borrow_mut()
        }
    }

要访问数据时(无论读还是写),需要首先调用 exclusive_access 获得数据的可变借用标记,通过它可以完成数据的读写,在操作完成之后我们需要销毁这个标记,此后才能开始对该数据的下一次访问。相比 RefCell 它不再允许多个读操作同时存在。
这段代码里面出现了两个 unsafe :

- 首先 new 被声明为一个 unsafe 函数,是因为我们希望使用者在创建一个 UPSafeCell 的时候保证在访问 UPSafeCell 内包裹的数据的时候始终不违背上述模式:即访问之前调用 exclusive_access ,访问之后销毁借用标记再进行下一次访问。这只能依靠使用者自己来保证,但我们提供了一个保底措施:当使用者违背了上述模式,比如访问之后忘记销毁就开启下一次访问时,程序会 panic 并退出。
- 另一方面,我们将 UPSafeCell 标记为 Sync 使得它可以作为一个全局变量。这是 unsafe 行为,因为编译器无法确定我们的 UPSafeCell 能否安全的在多线程间共享。而我们能够向编译器做出保证,第一个原因是目前我们内核仅运行在单核上,因此无需在意任何多核引发的数据竞争/同步问题;第二个原因则是它基于 RefCell 提供了运行时借用检查功能,从而满足了 Rust 对于借用的基本约束进而保证了内存安全。

这样,我们就以尽量少的 unsafe code 来初始化 AppManager 的全局实例 APP_MANAGER :

    // os/src/batch.rs

    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,
            }
        })};
    }

batch 子模块对外暴露出如下接口:

- init :调用 print_app_info 的时候第一次用到了全局变量 APP_MANAGER ,它也是在这个时候完成初始化;
- run_next_app :批处理操作系统的核心操作,即加载并运行下一个应用程序。当批处理操作系统完成初始化或者一个应用程序运行结束或出错之后会调用该函数。

## 实现特权级的切换

批处理操作系统为了建立好应用程序的执行环境,需要在执行应用程序之前进行一些初始化工作,并监控应用程序的执行,具体体现在:

- 当启动应用程序的时候,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序;
- 当应用程序发起系统调用(即发出 Trap)之后,需要到批处理操作系统中进行处理;
- 当应用程序执行出错的时候,需要到批处理操作系统中杀死该应用并加载运行下一个应用;
- 当应用程序执行结束的时候,需要到批处理操作系统中加载运行下一个应用(实际上也是通过系统调用 sys_exit 来实现的)。

这些处理都涉及到特权级切换,因此需要应用程序、操作系统和硬件一起协同,完成特权级切换机制。

需要注意两点:

- 在触发 Trap 之前 CPU 运行在哪个特权级;
- CPU 需要切换到哪个特权级来处理该 Trap ,并在处理完成之后返回原特权级。

在编写运行在 S 特权级的批处理操作系统中的 Trap 处理相关代码的时候,就需要使用如下所示的 S 模式的 CSR 寄存器。

| CSR名 | 该CSR与Trap相关的功能 |
| ------ | ------ |
|  sstatus|SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息     |
|sepc | 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址  |
|scause|描述 Trap 的原因|
|stval|给出 Trap 附加信息|
|stvec|控制 Trap 处理代码的入口地址|

#### 特权级切换的硬件控制机制

当 CPU 执行完一条指令(如 ecall )并准备从用户特权级 陷入( Trap )到 S 特权级的时候,硬件会自动完成如下这些事情:

- sstatus 的 SPP 字段会被修改为 CPU 当前的特权级(U/S)。
- sepc 会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。
- scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
- CPU 会跳转到 stvec 所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。

而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret 来完成,这一条指令具体完成以下功能:

- CPU 会将当前的特权级按照 sstatus 的 SPP 字段设置为 U 或者 S ;
- CPU 会跳转到 sepc 寄存器指向的那条指令,然后继续执行。

#### 用户栈与内核栈

在 Trap 触发的一瞬间, CPU 就会切换到 S 特权级并跳转到 stvec 所指示的位置。但是在正式进入 S 特权级的 Trap 处理之前,我们必须保存原控制流的寄存器状态,这一般通过内核栈来保存。我们需要用专门为操作系统准备的内核栈。

声明两个类型 KernelStack 和 UserStack 分别表示用户栈和内核栈,它们都只是字节数组的简单包装:

    // os/src/batch.rs

    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] };

常数 USER_STACK_SIZE 和 KERNEL_STACK_SIZE 指出内核栈和用户栈的大小分别为 8KiB 。两个类型是以全局变量的形式实例化在批处理操作系统的 .bss 段中的。

在 Trap 发生时需要保存的物理资源内容,并将其一起放在一个名为 TrapContext 的类型中,定义如下:

    // os/src/trap/context.rs

    #[repr(C)]
    pub struct TrapContext {
        pub x: [usize; 32],
        pub sstatus: Sstatus,
        pub sepc: usize,
    }

#### Trap 管理

主要涉及到如下一些内容:

- 应用程序通过 ecall 进入到内核状态时,操作系统保存被打断的应用程序的 Trap 上下文;
- 操作系统根据Trap相关的CSR寄存器内容,完成系统调用服务的分发与处理;
- 操作系统完成系统调用服务后,需要恢复被打断的应用程序的Trap 上下文,并通 sret 让应用程序继续执行。

修改 stvec 寄存器来指向正确的 Trap 处理入口点。

    // os/src/trap/mod.rs

    global_asm!(include_str!("trap.S"));

    pub fn init() {
        extern "C" { fn __alltraps(); }
        unsafe {
            stvec::write(__alltraps as usize, TrapMode::Direct);
        }
    }

Trap 处理的总体流程如下:首先通过 __alltraps 将 Trap 上下文保存在内核栈上,然后跳转到使用 Rust 编写的 trap_handler 函数完成 Trap 分发及处理。当 trap_handler 返回之后,使用 __restore 从保存在内核栈上的 Trap 上下文恢复寄存器。最后通过一条 sret 指令回到应用程序执行。


#### 系统调用与执行应用程序

对于系统调用而言, syscall 函数并不会实际处理系统调用,而只是根据 syscall ID 分发到具体的处理函数。

批处理操作系统初始化完成,或者是某个应用程序运行结束或出错的时候,要调用 run_next_app 函数切换到下一个应用程序。此时 CPU 运行在 S 特权级,执行 Trap 返回的特权指令,使得 CPU 特权级下降。如 sret 、mret 等。在从操作系统内核返回到运行应用程序之前,要完成如下这些工作:

- 构造应用程序开始执行所需的 Trap 上下文;
- 通过 __restore 函数,从刚构造的 Trap 上下文中,恢复应用程序执行的部分寄存器;
- 设置 sepc CSR的内容为应用程序入口点 0x80400000;
- 切换 scratch 和 sp 寄存器,设置 sp 指向应用程序用户栈;
- 执行 sret 从 S 特权级切换到 U 特权级。