├── lab0 └── 实验环境配置.md ├── lab1 ├── color-demo.png ├── exercise.md └── guide.md ├── lab2 ├── exercise.md └── guide.md ├── lab3 ├── exercise.md └── guide.md ├── lab4 ├── exercise.md └── guide.md ├── lab5 ├── exercise.md ├── guide.md └── shell.png ├── lab6 ├── exercise.md └── guide.md ├── lab7 ├── exercise.md └── guide.md ├── lab8 ├── exercise.md └── guide.md └── readme.md /lab0/实验环境配置.md: -------------------------------------------------------------------------------- 1 | # 实验环境配置 2 | 3 | - 注意:请使用教程中提供的qemu、rust-sbi、编译工具版本,用更老/更新的版本都可能会遇到各种不必要的麻烦,此环境配置文档目前在2021春季学期有效,如有问题会持续更新 4 | 5 | ## 1 如果使ubuntu或wsl2 6 | 7 | ### 1.1 系统环境配置 8 | 9 | 请参考[rCore系统配置章节](https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter0/5setup-devel-env.html#id2)。 10 | 11 | ### 1.2 qemu 配置 12 | 13 | 请参考[rCore qemu 配置章节](https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter0/5setup-devel-env.html#id2)。 14 | 15 | ### 1.3 riscv64 工具链 16 | 17 | * 安装riscv64-unknown-elf-gcc 18 | 19 | ``` 20 | # 安装位置 21 | cd /usr/local 22 | 23 | # 下载预编译好的工具 24 | sudo wget https://static.dev.sifive.com/dev-tools/freedom-tools/v2020.08/riscv64-unknown-elf-gcc-10.1.0-2020.08.2-x86_64-linux-ubuntu14.tar.gz 25 | 26 | # 解压缩 27 | tar xzvf riscv64-unknown-elf-gcc-10.1.0-2020.08.2-x86_64-linux-ubuntu14.tar.gz 28 | 29 | # 文件名改短 30 | mv riscv64-unknown-elf-gcc-10.1.0-2020.08.2-x86_64-linux-ubuntu14 riscv64-unknown-elf-gcc 31 | ``` 32 | 33 | 添加路径到PATH环境变量,将这里的内容添加到```.bashrc```: ```export PATH="/usr/local/riscv64-unknown-elf-gcc/bin:$PATH"``` 34 | 35 | * 安装musl-gcc 36 | 37 | ``` 38 | cd /usr/local 39 | sudo wget https://more.musl.cc/9.2.1-20190831/x86_64-linux-musl/riscv64-linux-musl-cross.tgz 40 | # 2021.3.1更新:上面的网站发生了"Catastrophic disk failure",暂时处于不可用状态。可从清华云盘下载:https://cloud.tsinghua.edu.cn/f/cc4af959a6fc469e8564/ 41 | tar xzvf riscv64-linux-musl-cross.tgz 42 | ``` 43 | 44 | 添加路径到PATH环境变量,将这里的内容添加到```.bashrc```: ```export PATH="/usr/local/riscv64-linux-musl-cross/bin:$PATH"``` 45 | 46 | * cmake 47 | 48 | ``` 49 | sudo apt install cmake 50 | ``` 51 | 52 | ## 2 如果使用docker 53 | 54 | - 使用docker可以免于自己配置环境的繁琐,我们把包含实验所需工具的docker镜像文件放在了dockerhub和阿里云的镜像仓库,供大家使用 55 | 56 | ### 2.1 docker image 使用 57 | 58 | 1. 安装docker. (https://www.docker.com/get-started) 59 | 2. 拉取docker镜像文件 60 | 61 | ``` 62 | # dockerhub: 63 | docker pull nzpznk/oslab-c-env 64 | 65 | # aliyun: 66 | docker pull registry.cn-hangzhou.aliyuncs.com/nzpznk/oslab-c-env 67 | ``` 68 | 69 | 3. 查看下载的镜像文件: ```docker image ls``` (nzpznk/oslab-c-env or registry.cn-hangzhou.aliyuncs.com/nzpznk/oslab-c-env) 70 | 4. 使用下载的镜像文件创建一个名字叫```container_name```(可自己指定)的容器,并获得容器的bash shell: ```docker run -it --name container_name image_name /bin/bash``` 71 | 5. 检查运行中的容器: ```docker ps```; 检查所有的容器(包括停止了的): ```docker ps -a``` 72 | 6. 停止/启动容器: ```docker stop/start container_name``` 73 | 7. 获得一个运行中的docker容器的bash shell: ```docker exec -it container_name /bin/bash``` 74 | 8. 删除一个容器(需要先停止):```docker rm container_name``` 75 | 9. 更多命令可自行查看docker的用户文档 76 | 77 | ### 2.2 把宿主机的文件目录挂载到docker容器上 78 | 79 | 在使用 ```docker run``` 启动容器时,你可以将目录挂载到容器上,这样就可以从docker容器访问到本地的某个文件夹. 80 | 81 | ```docker run -it --name container_name --mount type=bind,src=[absolute path of folder in host machine],dst=[absolute path in container] image_name /bin/bash``` 82 | 83 | 这样你就可以在宿主机写os代码,然后在docker容器中编译运行代码 84 | 85 | ### 3 代码下载与试运行 86 | 87 | ```shell 88 | git clone https://github.com/DeathWish5/ucore-Tutorial.git 89 | ``` 90 | 代码运行方式参考代码 readme 文档 91 | -------------------------------------------------------------------------------- /lab1/color-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeathWish5/ucore-Tutorial-Book/7346c2c4833697f6d2273776b09ef0440502848f/lab1/color-demo.png -------------------------------------------------------------------------------- /lab1/exercise.md: -------------------------------------------------------------------------------- 1 | # lab1 实验要求 2 | 3 | - 本节难度:**低** 4 | 5 | ## 编程作业 6 | 7 | lab1 的工作使得我们从硬件世界跳入了软件世界,当看到自己的小 os 可以在裸机硬件上输出 `hello world` 是不是很高兴呢?但是为了后续的一步开发,更好的调试环境也是必不可少的,第一章的练习要求大家实现更加炫酷的彩色log。 8 | 9 | 详细的原理不多说,感兴趣的同学可以参考 [ANSI转义序列](https://zh.wikipedia.org/wiki/ANSI%E8%BD%AC%E4%B9%89%E5%BA%8F%E5%88%97),现在执行如下这条命令试试 10 | 11 | ```console 12 | $ echo -e "\x1b[31mhello world\x1b[0m" 13 | ``` 14 | 15 | 如果你明白了我们是如何利用串口实现输出,那么要实现彩色输出就十分容易了,只需要用需要输出的字符串替换上一条命令中的 `hello world`,用期望颜色替换 `31` (代表红色)即可。 16 | 17 | > **注意:实现不同等级的输出不是实验要求,如下设定仅为推荐实现!** 18 | 19 | 我们推荐实现如下几个等级的输出,输出优先级依次降低: 20 | 21 | | 名称 | 颜色 | 用途 | 22 | | :---: | :------: | :------------------------------------------: | 23 | | ERROR | 红色(31) | 表示发生严重错误,很可能或者已经导致程序崩溃 | 24 | | WARN | 黄色(93) | 表示发生不常见情况,但是并不一定导致系统错误 | 25 | | INFO | 蓝色(34) | 比较中庸的选项,输出比较重要的信息,比较常用 | 26 | | DEBUG | 绿色(32) | 输出信息较多,在 debug 时使用 | 27 | | TRACE | 灰色(90) | 最详细的输出,跟踪了每一步关键路径的执行 | 28 | 29 | 30 | 我们要求输出设定输出等级以及更高输出等级的信息,如设置 `LOG = INFO`,则输出 `ERROR`、`WARN`、`INFO` 等级的信息。简单 demo 如下,输出等级为 INFO: 31 | 32 | ![image](color-demo.png) 33 | 34 | 为了方便使用彩色输出,我们要求同学们实现彩色输出的宏或者函数,用以代替 print 完成输出内核信息的功能,它们有着和 prinf 十分相似的使用格式,要求支持可变参数解析,形如: 35 | ```rust 36 | // 这段代码输出了 os 内存空间布局,这到这些信息对于编写 os 十分重要 37 |   38 | info!(".text [{:#x}, {:#x})", s_text as usize, e_text as usize); 39 | debug!(".rodata [{:#x}, {:#x})", s_rodata as usize, e_rodata as usize); 40 | error!(".data [{:#x}, {:#x})", s_data as usize, e_data as usize); 41 | ``` 42 | ``` c 43 | info("load range : [%d, %d] start = %d\n", s, e, start); 44 | ``` 45 | 46 | 在以后,我们还可以在 log 信息中增加线程、CPU等信息(只是一个推荐,不做要求),这些信息将极大的方便你的代码调试。 47 | 48 | ### 编程实验要求 49 | - 实现分支:ch1。 50 | - 完成实验指导书中的内容,在裸机上实现 `hello world` 输出。 51 | - 实现彩色输出宏(只要有彩色就好,不要求实现上述的不同等级log,不要求不同颜色,有时间的同学可以尝试)。 52 | - **隐性要求**: 实现一种机制可以关闭内核所有输出,lab2 开始要求关闭所有输出(如果实现了上述的 log,自然就实现了这一点)。 53 | - 使用彩色输出宏输出 os 内存空间布局,即:输出 `.text`、`.data`、`.rodata`、`.bss` 各段位置。 54 | 55 | challenge:实现多核 boot。 56 | 57 | ### 实验检查 58 | 59 | - 实验目录要求(C) 60 | 61 | ``` 62 | ├── os(内核实现) 63 | │   ├── Makefile (要求 make run LOG=xxx 可以正确执行) 64 | │   └── ... 65 | ├── reports(报告) 66 | │   ├── lab1.md/pdf 67 | │   └── ... 68 | ├── README.md(其他必要的说明) 69 | ├── ... 70 | ``` 71 | 72 | 报告命名 labx.md/pdf,统一放在 reports 目录下。每个实验新增一个报告,为了方便修改,检查报告是以最新分支的所有报告为准。 73 | 74 | - 检查 75 | 76 | ```console 77 | $ cd os 78 | $ git checkout ch1 79 | $ make run LOG=INFO 80 | ``` 81 | 可以正确执行(可以不支持LOG参数,而是设置默认log等级),可以看到正确的内存布局输出,根据实现不同数值可能有差异,但应该位于 ``linker.ld`` 中指示 ``BASE_ADDRESS`` 后一段内存,输出之后关机。 82 | 83 | ### tips 84 | 85 | - 对于 Rust, 可以使用 crate `log`,推荐参考 [rCore](https://github.com/rcore-os/rCore/blob/master/kernel/src/logging.rs) 86 | - 对于 C,可以实现不同的函数(注意不推荐多层可变参数解析,有时会出现不稳定情况),也可以参考 [linux printk](https://github.com/torvalds/linux/blob/master/include/linux/printk.h#L312-L385) 使用宏实现代码重用。 87 | - 两种语言都可以使用 `extern` 关键字获得在其他文件中定义的符号。 88 | 89 | ## 问答作业 90 | 91 | 1. 为了方便 os 处理,M态软件会将 S 态异常/中断委托给 S 态软件,请指出有哪些寄存器记录了委托信息,rustsbi 委托了哪些异常/中断?(也可以直接给出寄存器的值) 92 | 93 | 2. 请学习 gdb 调试工具的使用(这对后续调试很重要),并通过 gdb 简单跟踪从机器加电到跳转到 0x80200000 的简单过程。只需要描述重要的跳转即可,只需要描述在 qemu 上的情况。 94 | 95 | tips: 96 | * 事实上进入 rustsbi 之后就不需要使用 gdb 调试了。可以直接阅读代码。[rustsbi起始代码](https://github.com/luojia65/rustsbi/blob/master/platform/qemu/src/main.rs#L93) 97 | * 可以使用示例代码 Makefile 中的 `make debug` 指令。 98 | 99 | * 一些可能用到的 gdb 指令: 100 | * `x/10i 0x80000000` : 显示 0x80000000 处的10条汇编指令。 101 | * `x/10i $pc` : 显示即将执行的10条汇编指令。 102 | * `x/10xw 0x80000000` : 显示 0x80000000 处的10条数据,格式为16进制32bit。 103 | * `info register`: 显示当前所有寄存器信息。 104 | * `info r t0`: 显示 t0 寄存器的值。 105 | * `break funcname`: 在目标函数第一条指令处设置断点。 106 | * `break *0x80200000`: 在 0x80200000 出设置断点。 107 | * `continue`: 执行直到碰到断点。 108 | * `si`: 单步执行一条汇编指令。 109 | 110 | ## 报告要求 111 | 112 | * 简单总结本次实验你编程的内容。(控制在5行以内,不要贴代码) 113 | * 由于彩色输出不好自动测试,请附正确运行后的截图。 114 | * 完成问答问题。 115 | * (optional) 你对本次实验设计及难度的看法。 -------------------------------------------------------------------------------- /lab1/guide.md: -------------------------------------------------------------------------------- 1 | # lab1 RV64裸机应用 2 | 3 | 该章节我们将完成一个能够在屏幕输出的小 os,也就是从 bootloader 手中接过程序执行的接力棒,这是后续所有实验的基础。 4 | 5 | 同学们可以通过 6 | 7 | ```shell 8 | git checkout ch1 9 | ``` 10 | 来查看本章对应代码。 11 | 12 | ## 基本项目结构 13 | 14 | ``` 15 | ├── kernel 16 | │ ├── entry.S 17 | │ ├── kernel.ld 18 | │ ├── main.c 19 | │ ├── Makefile 20 | │ └── ... 21 | ├── bootloader 22 | │ └── rustsbi-qemu.bin 23 | └── readme.md 24 | ``` 25 | 26 | ## Makefile 与 qemu 27 | 28 | 我们希望在 kernel 目录下可以通过 `make run` 运行,简单看一下 Makefile 中 `make run` 之后发生了什么。 29 | 30 | ```makefile 31 | SRCS = $(wildcard *.S *.c) 32 | OBJS = $(addsuffix .o, $(basename $(SRCS))) 33 | 34 | kernel: $(OBJS) kernel.ld 35 | $(LD) $(LDFLAGS) -T kernel.ld -o kernel $(OBJS) 36 | 37 | QEMU = qemu-system-riscv64 38 | QEMUOPTS = \ 39 | -nographic \ 40 | -smp 1 \ 41 | -machine virt \ 42 | -bios $(BOOTLOADER) \ 43 | -device loader,addr=0x80200000,file=kernel 44 | 45 | run: kernel 46 | $(QEMU) $(QEMUOPTS) 47 | ``` 48 | 49 | 可以看到执行流程为: 50 | * 编译所有 .c .S 文件并按照 kernel.ld 链接,得到 elf 文件 kernel 51 | * 运行 qemu,参数含义([详细参考](https://qemu.readthedocs.io/en/latest/system/invocation.html#)): 52 | * -nographic: 无图形界面 53 | * -smp 1: 单核 54 | * -machine virt: 模拟硬件 RISC-V VirtIO Board 55 | * -bios ...: 使用制定 bios 56 | * -device loader ...: 增加 loader 类型的 device, 这里其实就是把我们的 os 文件放到制定位置。 57 | 58 | 那么,为什么要把 os 起始位置放到 0x80200000 这个位置呢?这其实是我们使用的 bootloader 也就是 rustsbi 的要求。 59 | 60 | ## riscv 与 RustSBI 61 | 62 | 我们都知道 riscv 硬件加点之后位于 M 态,但我们编写的 os 运行在 S 态,是谁帮我们完成了 M -> S 的过度呢?正是我们使用的 [rustsbi](https://github.com/luojia65/rustsbi)。 63 | 目前阶段我们只需要知道 rustsbi 帮我们干了两件事情 64 | * 完成 M 态的初始化,进行 S 态中断委托,进入 S 态同时跳转到 S 态软件初始位置。 65 | * 当 S 态发出 `ecall` 请求时完成该请求并返回。 66 | 67 | 而 rustsbi 指定的 S 态初始位置也就是 0x80200000,这也是我们的第一行代码执行的位置。那么这个位置放的是什么代码? 68 | 69 | [sbi文档](https://github.com/riscv/riscv-sbi-doc/blob/master/riscv-sbi.adoc)。 70 | 71 | ## 链接脚本 72 | 73 | 请先阅读[内存布局参考](https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter1/4understand-prog.html#id8)。 74 | 75 | 还记得我们的 os 是使用 kernel.ld 链接的。链接脚本决定了 elf 程序的内存空间布局(严格的讲是虚存映射,注意程序中的各种绝对地址就在链接的时候确定),由于刚进入 S 态的时候我们尚未激活虚存机制,我们必须把 os 置于物理内存的 0x80200000 处。 76 | 77 | ``` 78 | // linker.ld 79 | 80 | BASE_ADDRESS = 0x80200000; 81 | 82 | SECTIONS 83 | { 84 | . = BASE_ADDRESS; 85 | skernel = .; 86 | 87 | stext = .; 88 | .text : { 89 | *(.text.entry) # 第一行代码 90 | *(.text .text.*) 91 | } 92 | 93 | ... 94 | } 95 | ``` 96 | 97 | 从链接脚本可知,os 的第一个 section (也就是内存中的第一个段) 是 text 段(代码段),而 text 段 由不同文件中的 text 段组成,我们没有规定这些 text 段的具体顺序,但是我们规定了一个特殊的 text 段:.text.entry 段,该 text 段是 BASE_ADDRESS 后的第一个段,该段的第一行代码就在 0x80200000 处。这个特殊的段不是编译生成的,它在 entry.S 中人为设定。 98 | 99 | ## 第一行代码与 main() 100 | 101 | ```assembly 102 | # entry.S 103 | 104 | .section .text.entry 105 | .globl _entry 106 | _entry: 107 | la sp, boot_stack 108 | call main 109 | 110 | .section .bss.stack 111 | .globl boot_stack 112 | boot_stack: 113 | .space 4096 * 16 114 | .globl boot_stack_top 115 | boot_stack_top: 116 | 117 | ``` 118 | 119 | .text.entry 段中只有一个函数 _entry,它干的事情也十分简单,设置好 os 运行的堆栈(bootloader 并没有好心的设置好这些),然后调用 main 函数。main 函数位于 main.c 中,从此开始我们就基本进入了 C 的世界。 120 | 121 | ```c 122 | void clean_bss() { 123 | char* p; 124 | for(p = sbss; p < ebss; ++p) 125 | *p = 0; 126 | } 127 | 128 | void main() { 129 | clean_bss(); 130 | printf("\n"); 131 | printf("hello wrold!\n"); 132 | printf("stext: %p\n", stext); 133 | printf("etext: %p\n", etext); 134 | printf("sroda: %p\n", srodata); 135 | printf("eroda: %p\n", erodata); 136 | printf("sdata: %p\n", sdata); 137 | printf("edata: %p\n", edata); 138 | printf("sbss : %p\n", sbss); 139 | printf("ebss : %p\n", ebss); 140 | printf("\n"); 141 | shutdown(); 142 | } 143 | ``` 144 | 145 | main 函数也十分简单,首先初始化了 .bss 段(正常来说 os 会负责 .bss 段的清空,但现在我们只能自己来了),然后输出了一些内存布局,最后关机。问题在于,在没有 libc 库甚至没有 os 的情况下,输出和关机是如何实现的?这就是 sbi 给我们带来的便利了。 146 | 147 | ## 串口输出与关机 148 | 149 | ```c 150 | // sbi.c 151 | const uint64 SBI_CONSOLE_PUTCHAR = 1; 152 | const uint64 SBI_SHUTDOWN = 8; 153 | 154 | int inline sbi_call(uint64 which, uint64 arg0, uint64 arg1, uint64 arg2) { 155 | register uint64 a0 asm("a0") = arg0; 156 | register uint64 a1 asm("a1") = arg1; 157 | register uint64 a2 asm("a2") = arg2; 158 | register uint64 a7 asm("a7") = which; 159 | asm volatile("ecall" 160 | : "=r"(a0) 161 | : "r"(a0), "r"(a1), "r"(a2), "r"(a7) 162 | : "memory"); 163 | return a0; 164 | } 165 | 166 | void console_putchar(int c) { 167 | sbi_call(SBI_CONSOLE_PUTCHAR, c, 0, 0); 168 | } 169 | 170 | void shutdown() { 171 | sbi_call(SBI_SHUTDOWN, 0, 0, 0); 172 | panic("shutdown"); 173 | } 174 | 175 | ``` 176 | 177 | 在 sbi.c 中,我们使用嵌入式汇编,调用 ecall 指令向 M 态,也就是向 rustsbi 发出了服务请求,rustsbi 会帮助我们完成串口输出和关机,这极大的方便了我们的开发。 178 | 179 | printf 的实现见 `printf.c`,这是一个十分简化的实现(甚至可以注入一些恶意代码),但基本能满足我们的需求。 180 | 181 | ## 展望 182 | 183 | 至此我们搭建了一个十分基本的开发环境,接下来的 exercise 中,我们将为我们的 os 增加更加炫酷的输出功能,而 lab2 中我们将引入用户态,并实现第一个系统调用 sys_write。 -------------------------------------------------------------------------------- /lab2/exercise.md: -------------------------------------------------------------------------------- 1 | # lab2 实验要求 2 | 3 | - 本节难度: **低** 4 | 5 | ## 编程作业 6 | 7 | ### 简单安全检查 8 | 9 | lab2 中,我们实现了第一个系统调用 `sys_write`,这使得我们可以在用户态输出信息。但是 os 在提供服务的同时,还有保护 os 本身以及其他用户程序不受错误或者恶意程序破坏的功能。 10 | 11 | 由于还没有实现虚拟内存,我们可以在用户程序中指定一个属于其他程序字符串,并将它输出,这显然是不合理的,因此我们要对 sys_write 做检查: 12 | 13 | - 传入的 **fd** 是否合法(目前仅支持 stdout,也就是 1) 14 | - 传入缓冲区是否位于用户地址之外(需要检查 .text .data .bss 各段以及用户栈,如果是 bin 格式会简单很多) 15 | 16 | ### 实验要求 17 | 18 | - 实现分支 ch2。 19 | - 完成实验指导书中的内容,能运行用户态程序并执行 sys_write 和 sys_exit 系统调用。 20 | - 增加对 sys_write 的安全检查,通过[C测例](https://github.com/DeathWish5/riscvos-c-tests) 中 chapter2 对应的所有测例,测例详情见对应仓库,系统调用具体要求参考[这里](https://github.com/DeathWish5/riscvos-c-tests/blob/main/guide.md#lab2)。 21 | 22 | challenge: 实现多核,可以并行执行用户程序。 23 | 24 | ### 实验约定 25 | 26 | 在第二章的测试中,我们对于内核有如下仅仅为了测试方便的要求,请调整你的内核代码来符合这些要求。 27 | 28 | * 用户栈大小必须为 4096,且按照 4096 字节对其。 29 | 30 | ### 实验检查 31 | 32 | - 实验目录要求 33 | 34 | 目录要求不变(参考lab1目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。 35 | 36 | 加载的用户测例位置: `../user/target/bin`。 37 | 38 | 可以先参考示例代码 [pack.py](https://github.com/DeathWish5/ucore-Tutorial/blob/ch2/kernel/pack.py) 39 | 40 | - 检查 41 | 42 | ```console 43 | $ cd os 44 | $ git checkout ch2 45 | $ make run 46 | ``` 47 | 可以正确执行正确执行目标用户测例,并得到预期输出(详见测例注释)。 48 | 49 | 注意:如果设置默认 log 等级,从 lab2 开始关闭所有 log 输出。 50 | 51 | ## 问答作业 52 | 53 | 1. 正确进入 U 态后,程序的特征还应有:使用 S 态特权指令,访问 S 态寄存器后会报错。目前由于一些其他原因,这些问题不太好测试,请同学们可以自行测试这些内容(参考[前三个测例](https://github.com/DeathWish5/riscvos-c-tests/tree/main/user/src)),描述程序出错行为,同时注意注明你使用的 sbi 及其版本。 54 | 55 | 2. 请结合用例理解 [trampoline.S](https://github.com/DeathWish5/ucore-Tutorial/blob/ch2/kernel/trampoline.S) 中两个函数 `userret` 和 `uservec` 的作用,并回答如下几个问题: 56 | 57 | 1. L79: 刚进入 `userret`时,`a0`、`a1`分别代表了什么值。 58 | 59 | 1. L87-L88: `sfence` 指令有何作用?为什么要执行该指令,当前章节中,删掉该指令会导致错误吗? 60 | ``` 61 | csrw satp, a1 62 | sfence.vma zero, zero 63 | ``` 64 | 65 | 1. L96-L125: 为何注释中说要除去 `a0`?哪一个地址代表 `a0`?现在 `a0` 的值存在何处? 66 | ```assembly 67 | # restore all but a0 from TRAPFRAME 68 | ld ra, 40(a0) 69 | ld sp, 48(a0) 70 | ld t5, 272(a0) 71 | ld t6, 280(a0) 72 | ``` 73 | 74 | 1. `userret`:中发生状态切换在哪一条指令?为何执行之后会进入用户态? 75 | 76 | 1. L29: 执行之后,a0 和 sscratch 中各是什么值,为什么? 77 | ```assembly 78 | csrrw a0, sscratch, a0 79 | ``` 80 | 81 | 1. L32-L61: 从 trapframe 第几项开始保存?为什么?是否从该项开始保存了所有的值,如果不是,为什么? 82 | ```assembly 83 | sd ra, 40(a0) 84 | sd sp, 48(a0) 85 | ... 86 | sd t5, 272(a0) 87 | sd t6, 280(a0) 88 | ``` 89 | 90 | 1. 进入 S 态是哪一条指令发生的? 91 | 92 | 1. L75-L76: `ld t0, 16(a0)` 执行之后,`t0`中的值是什么,解释该值的由来? 93 | ```assembly 94 | ld t0, 16(a0) 95 | jr t0 96 | ``` 97 | 98 | 3. 描述程序陷入内核的两大原因是中断和异常,请问 riscv64 支持那些中断/异常?如何判断进入内核是由于中断还是异常?描述陷入内核时的几个重要寄存器及其值。 99 | 100 | 4. 对于任何中断, `uservec` 中都需要保存所有寄存器吗?你有没有想到一些加速 `uservec` 的方法?简单描述你的想法。 101 | 102 | 103 | ## 报告要求 104 | 105 | * 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) 106 | * 完成问答问题 107 | * (optional) 你对本次实验设计及难度的看法。 108 | -------------------------------------------------------------------------------- /lab2/guide.md: -------------------------------------------------------------------------------- 1 | # 批处理系统 2 | 3 | 这一章我们将进入用户态的世界,完成一个能够顺序执行用户任务的 os。 这主要是模拟了早期硬件内存十分有限的场景,内存小到我们在执行第二个任务的时候需要把第一个任务从内存中丢掉。 4 | 5 | 你可以在 ch2 分支中看到文档对应的代码。 6 | 7 | ## 用户程序的载入 8 | 9 | 由于我们还没有文件系统,我们采取直接将用户程序打包到内核镜像的方法装载用户程序。 10 | 11 | 首先生成用户程序,由于要编译到我们自己的 os 上,我们不能使用 libc 库,而是链接我们自定义的程序库。cmake 是强大的程序生成工具,我们使用它来完成用户程序的构建。你可以在新增的 `user/` 目录下找到用户程序和自定义程序库。 12 | 13 | ```c 14 | // user/lib/syscall.c 15 | ssize_t write(int fd, const void *buf, size_t len) { 16 | return syscall(SYS_write, fd, buf, len); 17 | } 18 | 19 | void exit(int code) { 20 | syscall(SYS_exit, code); 21 | } 22 | ``` 23 | 24 | 自定义库(`user/lib`)中看到我们给自己定的小目标,`sys_write` 系统调用和 `sys_exit` 系统调用。详细接口见[这里](https://github.com/DeathWish5/riscvos-c-tests/blob/main/guide.md#lab2)。 25 | 26 | ```c 27 | // user/src/hello.c 28 | #include 29 | #include 30 | 31 | int main() { 32 | puts("hello wrold!"); 33 | return 0; 34 | } 35 | ``` 36 | 37 | `user/src`目录下是我们要运行的用户程序,lab2的还十分简单,就是简单的计算和输出。 38 | 39 | ```makefile 40 | # user/Makefile 41 | elf: 42 | @mkdir -p build 43 | @cd build && cmake $(cmake_build_args) .. && make -j 44 | 45 | bin: elf 46 | @mkdir -p asm 47 | @$(CP) build/asm/* asm 48 | @mkdir -p $(out_dir) 49 | @$(CP) build/target/* target 50 | ``` 51 | 52 | `user/Makefile`中可以看到用户程序生成的过程,我们首先使用 camke 得到 elf/bin 程序已经汇编代码(具体内容参见`CMakeLists.txt`文件,不做赘述),然后将对应文件拷贝到特定目录(`user/target/bin`)。 53 | 54 | 然后我们使用一个 python 脚本 `pack.py` 生成 `link_app.S`,后者大致内容如下: 55 | 56 | ```assembly 57 | .align 4 58 | .section .data 59 | .global _app_num 60 | _app_num: 61 | .quad 2 62 | .quad app_0_start 63 | .quad app_1_start 64 | .quad app_1_end 65 | 66 | .global _app_names 67 | _app_names: 68 | .string "hello.bin" 69 | .string "matrix.bin" 70 | 71 | .section .data.app0 72 | .global app_0_start 73 | app_0_start: 74 | .incbin "../user/target/hello.bin" 75 | 76 | .section .data.app1 77 | .global app_1_start 78 | app_1_start: 79 | .incbin "../user/target/matrix.bin" 80 | app_1_end: 81 | ``` 82 | 83 | 可以看到,这个汇编文件使用 [incbin](https://www.keil.com/support/man/docs/armasm/armasm_dom1361290017052.htm) 将目标用户程序包含入 `link_app.S`中,同时记录了这些程序的地址和名称信息。最后,我们在 `Makefile` 中会将内核与 `link_app.S` 一同编译并链接。这样,我们在内核中就可以通过 `extern` 指令访问到用户程序的所有信息。 84 | 85 | 由于 riscv 要求程序指令必须是对齐的,我们对内核链接脚本也作出修改,保证用户程序链接时的指令对齐,这些内容见 `kernel/kernelld.py`。最终修改后的脚本中多了如下对齐要求: 86 | 87 | ```diff 88 | .data : { 89 | *(.data) 90 | + . = ALIGN(0x1000); 91 | + *(.data.app0) 92 | + . = ALIGN(0x1000); 93 | + *(.data.app1) 94 | *(.data.*) 95 | } 96 | ``` 97 | 98 | ## 内核 relocation 99 | 100 | 内核中通过访问 `link_app.S` 中定义的 `_app_num`、`app_0_start` 等符号来获得用户程序位置。 101 | 102 | ```c 103 | // kernel/batch.c 104 | extern char _app_num[]; 105 | void batchinit() { 106 | app_info_ptr = (uint64*) _app_num; 107 | app_num = *app_info_ptr; 108 | app_info_ptr++; 109 | // from now on: 110 | // app_n_start = app_info_ptr[n] 111 | // app_n_end = app_info_ptr[n+1] 112 | } 113 | ``` 114 | 115 | 然而我们并不能直接跳转到 `app_n_start` 直接运行,因为用户程序在编译的时候,会假定程序处在虚存的特定位置,而由于我们还没有虚存机制,因此我们在运行之前还需要将用户程序加载到规定的物理内存位置。 116 | 117 | > 虽然现在有很多相对寻址的技术,但是为了更好的支持诸如动态链接等技术,其实我们编译出的程序并不是完全相对寻址的。观察我们编译出的程序就可以发现,程序对与 .data 段的访问是基于 GOT 表的间接寻址。理论上 os 需要在加载的时候修改 GOT 表来完成 relocation,但这样反而更加复杂。 118 | 119 | 为此我们规定了用户的链接脚本,并在内核完成程序的 "搬运": 120 | 121 | ```linker.ld 122 | # user/lib/arch/riscv/user.ld 123 | SECTIONS { 124 | . = 0x80400000; # 规定了内存加载位置 125 | 126 | .startup : { 127 | *crt.S.o(.text) # 确保程序入口在程序开头 128 | } 129 | 130 | .text : { *(.text) } 131 | .data : { *(.data .rodata) } 132 | 133 | /DISCARD/ : { *(.eh_*) } 134 | } 135 | ``` 136 | ```c 137 | // kernel/batch.c 138 | const uint64 BASE_ADDRESS = 0x80400000, MAX_APP_SIZE = 0x20000; 139 | int load_app(uint64* info) { 140 | uint64 start = info[0], end = info[1], length = end - start; 141 | memset((void*)BASE_ADDRESS, 0, MAX_APP_SIZE); 142 | memmove((void*)BASE_ADDRESS, (void*)start, length); 143 | return length; 144 | } 145 | 146 | ``` 147 | 148 | ## 用户程序启动与中断返回 149 | 150 | 现在我们们可以直接跳转到程序开头开始运行吗?显然没这么简单,os为了保护自己,需要与用户程序进行特权级的隔离(U态和S态),在两个状态之间切换不能通过 function call,事实上编译器是没有这个能力的,我们需要设计一段代码进行这个过程。 151 | 152 | 首先,在执行流之间切换需要进行状态保存与恢复,按照 riscv 标准,`trap.h` 中的 `trapframe` 结构定义了需要保存和回复的内容。 153 | 154 | ```c 155 | // kernel/trap.h 156 | struct trapframe { 157 | /* 0 */ uint64 kernel_satp; // kernel page table 158 | /* 8 */ uint64 kernel_sp; // top of process's kernel stack 159 | /* 16 */ uint64 kernel_trap; // usertrap entry 160 | /* 24 */ uint64 epc; // saved user program counter 161 | /* 32 */ uint64 kernel_hartid; // saved kernel tp, unused in our project 162 | /* 40 */ uint64 ra; 163 | /* 48 */ uint64 sp; 164 | /* ... */ .... 165 | /* 272 */ uint64 t5; 166 | /* 280 */ uint64 t6; 167 | }; 168 | ``` 169 | 170 | 这其中 40-280 的项保存用户通用寄存器信息,0-32 保存一些内核信息。 171 | 172 | `trap.c` 的 `usertrapret()` 和 `trampoline.S` 中的 `userret` 函数展示了从S态返回/进入U态的过程,也就是当中断处理完毕后返回用户态的过程。这两个函数请同学们仔细理解。 173 | 174 | ```c 175 | void usertrapret(struct trapframe* trapframe, uint64 kstack) 176 | { 177 | // 这两个有啥用?往后看! 178 | trapframe->kernel_sp = kstack + PGSIZE; 179 | trapframe->kernel_trap = (uint64)usertrap; 180 | 181 | // 设定返回地址 182 | w_sepc(trapframe->epc); 183 | 184 | // set up the registers that trampoline.S's sret will use 185 | // to get to user space. 186 | // set S Previous Privilege mode to User. 187 | uint64 x = r_sstatus(); 188 | x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode 189 | x |= SSTATUS_SPIE; // enable interrupts in user mode 190 | w_sstatus(x); 191 | 192 | userret((uint64)trapframe); 193 | } 194 | ``` 195 | 196 | ```assembly 197 | .globl userret 198 | userret: 199 | # a0 = bottom of trapframe 200 | # put the saved user a0 in sscratch, so we 201 | # can swap it with our a0 (TRAPFRAME) in the last step. 202 | ld t0, 112(a0) 203 | csrw sscratch, t0 204 | 205 | # restore all but a0 from TRAPFRAME 206 | ld ra, 40(a0) 207 | # ... 48-272 208 | ld t6, 280(a0) 209 | 210 | # restore user a0, and **save TRAPFRAME in sscratch** 211 | csrrw a0, sscratch, a0 212 | 213 | # return to user mode and user pc. 214 | # usertrapret() set up sstatus and sepc. 215 | sret 216 | 217 | ``` 218 | 219 | 注意 userret 最终在 scratch 中储存了 trapframe 的位置。 220 | 221 | 用户程序的创建后的第一次运行也是通过 `usertrapret` 完成的,我们会人为构造程序的 `trapframe`,设定返回地址和用户栈顶: 222 | 223 | ```c 224 | // kernel/batch.c 225 | __attribute__ ((aligned (4096))) char user_stack[4096]; // 预定义的用户 stack 226 | __attribute__ ((aligned (4096))) char trap_page[4096]; // 预定义的中断页,用来存放 trapframe 227 | // run_first_app 也使用这个函数 228 | int run_next_app() { 229 | struct trapframe* trapframe = (struct trapframe*)trap_page; 230 | app_info_ptr++; // go to the location of next user-app 231 | load_app(app_info_ptr); 232 | memset(trapframe, 0, 4096); 233 | // 设置 trapframe 234 | trapframe->epc = BASE_ADDRESS; // epc 也就是返回地址,设置为用户程序起始地址 235 | trapframe->sp = (uint64) user_stack + 4096; // 设置为用户栈顶 236 | usertrapret(trapframe, (uint64)boot_stack); // 调用 usertrapret 启动用户程序 237 | return 0; 238 | } 239 | ``` 240 | 241 | 通过巧妙的设置 `trapframe`(其实只是设置了 `epc` 和 `sp`,因为程序开始运行时对其他寄存器没有要求),我们复用了用户态中断处理完成后返回的函数,使得使用该 `trapframe` 返回后,用户程序刚好可以开始执行。用户进程中断处理的全流程图示如下: 242 | 243 | ```c 244 | 245 | 1.程序进行系统调用或者出现异常 -> 2.进入内核态 -> ... 3.内核态完成系统调用 ... -> 4. 返回用户态 -> 5.用户态继续执行 246 | 247 | ``` 248 | 249 | 而开始运行一个进程就是其中的 `4` `5` 两步骤。 250 | 251 | 此外当一个应用退出后,也会调用 `run_next_app`开始运行下一个应用,直到全部应用结束。 252 | 253 | ## 用户中断处理 254 | 255 | 如何返回 U 态我们已经知道了,那么 U 态发生错误之后如何正确处理呢?也就是如何从 U 态进入 S 态呢?首先,U 进入 S 都是因为中断或者异常,我们首先需要作出如下配置: 256 | 257 | ```c 258 | // set up to take exceptions and traps while in the kernel. 259 | void trapinit(void) 260 | { 261 | w_stvec((uint64)uservec & ~0x3); // 写 stvec, 最后两位表明跳转模式,该实验始终为 0 262 | } 263 | ``` 264 | 265 | 这个函数填写 `stvec` 寄存器,用于保存中断发生时跳转到的地址,也就是中断处理函数入口地址。当 U 态发生中断(系统调用)或者异常时,硬件会完成一些中断寄存器的保存,最重要的就是 `sepc` 寄存器,表明了中断发生的地址,它将帮助我们正确的返回。此外还有 sstatus、scause、stval,含义请查阅手册。我们来看看 `stvec` 的值,也就是 `uservec` 函数干了那些事情。 266 | 267 | ```assembly 268 | .globl uservec 269 | uservec: 270 | # 271 | # trap.c sets stvec to point here, so 272 | # traps from user space start here, 273 | # in supervisor mode, but with a 274 | # user page table. 275 | # 276 | # sscratch points to where the process's p->trapframe is 277 | # mapped into user space, at TRAPFRAME. 278 | # 279 | 280 | # swap a0 and sscratch 281 | # so that **a0 is TRAPFRAME** 282 | csrrw a0, sscratch, a0 283 | 284 | # save the user registers in TRAPFRAME 285 | sd ra, 40(a0) 286 | # ... 48-272 287 | sd t6, 280(a0) 288 | 289 | # save the user a0 in p->trapframe->a0 290 | csrr t0, sscratch 291 | sd t0, 112(a0) 292 | 293 | csrr t1, sepc 294 | sd t1, 24(a0) 295 | 296 | ld sp, 8(a0) // 想想看这是在干啥 297 | ld tp, 32(a0) 298 | ld t1, 0(a0) 299 | ld t0, 16(a0) 300 | jr t0 301 | ``` 302 | 303 | 可以看到 uservec 在 trapframe 中保存了基础寄存器,然后就跳转到了我们早先设定在 `trapframe->kernel_trap` 中的地址,也就是 `usertrap` 函数,该函数完成中断处理与返回: 304 | 305 | ```c 306 | // kernel/trap.c 307 | 308 | // 309 | // handle an interrupt, exception, or system call from user space. 310 | // called from trampoline.S 311 | // 312 | void usertrap(struct trapframe *trapframe) 313 | { 314 | if((r_sstatus() & SSTATUS_SPP) != 0) 315 | panic("usertrap: not from user mode"); 316 | 317 | uint64 cause = r_scause(); 318 | // 如果是一个系统调用,处理并返回 319 | if(cause == UserEnvCall) { 320 | trapframe->epc += 4; 321 | syscall(); 322 | return usertrapret(trapframe, (uint64)boot_stack); 323 | } 324 | // 否则报错并杀死进程,目前我们只处理系统调用 325 | switch(cause) { 326 | case StoreFault: 327 | // ... 报错信息 328 | default: 329 | printf("unknown trap: %p, stval = %p sepc = %p\n", r_scause(), r_stval(), r_sepc()); 330 | break; 331 | } 332 | printf("switch to next app\n"); 333 | run_next_app(); 334 | } 335 | 336 | ``` 337 | 338 | 至此,我们只需要完成系统调用的处理,也就是 `syscall` 函数就完成第二章的基础功能了,这部分逻辑在 `syscall.c` 中,由于十分简单,不做赘述。 339 | 340 | ## 展望 341 | 342 | 第二章,我们成功建立了用户态执行环境,可以运行用户程序。但是目前我们只能现行的运行,这有很大的缺陷。第三章,我们的内存就变大到足以同时容纳多个用户程序了,我们将实现多任务的调度与切换。 -------------------------------------------------------------------------------- /lab3/exercise.md: -------------------------------------------------------------------------------- 1 | # lab3 实验要求 2 | 3 | - 本节难度: **并不那么简单了!早点动手** 4 | 5 | ## 编程作业 6 | 7 | ### stride 调度算法 8 | 9 | lab3中我们引入了任务调度的概念,可以在不同任务之间切换,目前我们实现的调度算法十分简单,存在一些问题且不存在优先级。现在我们要为我们的 os 实现一种带优先级的调度算法:stide 调度算法。 10 | 11 | 算法描述如下: 12 | 1. 为每个进程设置一个当前 stride,表示该进程当前已经运行的“长度”。另外设置其对应的 pass 值(只与进程的优先权有关系),表示对应进程在调度后,stride 需要进行的累加值。 13 | 1. 每次需要调度时,从当前 runnable 态的进程中选择 stride 最小的进程调度。对于获得调度的进程 P,将对应的 stride 加上其对应的步长 pass。 14 | 1. 一个时间片,回到 2.步骤,重新调度当前 stride 最小的进程。 15 | 16 | 可以证明,如果令 P.pass = BigStride / P.priority 其中 P.priority 表示进程的优先权(大于 1),而 BigStride 表示一个预先定义的大常数,则该调度方案为每个进程分配的时间将与其优先级成正比。证明过程我们在这里略去,有兴趣的同学可以在网上查找相关资料。 17 | 18 | 其他实验细节: 19 | 1. stride 调度要求进程优先级 >= 2,所以设定进程优先级 <= 1 会导致错误。 20 | 1. 进程初始 stride 设置为 0 即可。 21 | 1. 进程初始优先级设置为 16。 22 | 23 | tips: 使用优先级队列是实现 stride 算法的不错选择。但是我们的实验不要求效率和优雅,可能直接遍历选找更加省事。 24 | 25 | ### 实验要求 26 | 27 | - 实现分支:ch3。 28 | - 完成实验指导书中的内容,实现 sys_yield,实现协作式和抢占式的调度。 29 | - 实现 stride 调度算法,实现 sys_gettime, sys_set_priority 两个系统调用并通过[C测例](https://github.com/DeathWish5/riscvos-c-tests) 中 chapter3 对应的所有测例,测例详情见对应仓库,系统调用具体要求参考[这里](https://github.com/DeathWish5/riscvos-c-tests/blob/main/guide.md#lab3)。 30 | 31 | 需要说明的是 lab3 有3类测例,`ch3_0_*` 用来检查基本 syscall 的实现,`ch3_1_*` 基于 yield 来检测基本的调度,`ch3_2_*` 基于时钟中断来测试 stride 调度算法实现的正确性。测试时可以分别测试 3 组测例,使得输出更加可控、更加清晰。 32 | 33 | challenge: 实现多核,可以并行调度。 34 | 35 | ### 实验约定 36 | 37 | 在第二章的测试中,我们对于内核有如下仅仅为了测试方便的要求,请调整你的内核代码来符合这些要求。 38 | 39 | * 我们有一个死循环测例 `ch3t_deadloop` 用来保证大家真的实现了始终中断。这一章中我们人为限制一个程序执行的最大时间(如 5s),超过就杀死,这样,我们的程序更不容易被恶意程序伤害。这一规定可以在实验4开始删除,仅仅为通过 lab3 测例设置。 40 | 41 | ### 实验检查 42 | 43 | - 实验目录要求 44 | 45 | 目录要求不变(参考lab1目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。 46 | 47 | 加载的用户测例位置: `../user/target/bin`。 48 | 49 | - 检查 50 | 51 | 可以正确 `make run` 执行,可以正确执行目标用户测例,并得到预期输出(详见测例注释)。 52 | 53 | ## 问答作业 54 | 55 | 考虑在一个完整的 os 中,随时可能有新进程产生,新进程在调度池中的位置见[chapter5相关代码](https://github.com/DeathWish5/ucore-Tutorial/blob/ch5/kernel/proc.c#L90-L98)。 56 | 57 | 1. 请分析[chapter3示例代码](https://github.com/DeathWish5/ucore-Tutorial/blob/ch3/kernel/proc.c#L60-L74)调度策略。 58 | 59 | * 指出调度时机,下一个进程选择,新进程处理方式(参见 lab5 代码)。 60 | * 尽可能多的指出该策略存在的缺点。 61 | 62 | 2. 该调度策略在公平性上存在比较大的问题,请找到一个进程产生和结束的时间序列,使得在该调度算法下发生:先创建的进程后执行的现象。你需要给出类似下面例子的信息(有更详细的分析描述更好,但尽量精简)。同时指出该序列在你实现的 stride 调度算法下顺序是怎样的? 63 | 64 | 例子: 65 | 66 | | 时间 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 67 | | :------: | :-------------: | :--: | :----: | :----: | :----: | :----: | :----: | :--: | 68 | | 运行进程 | - | p1 | p2 | p3 | p1 | p4 | p3 | - | 69 | | 事件 | p1、p2、p3 产生 | | p2结束 | p4产生 | p1结束 | p4结束 | p3结束 | - | 70 | 71 | 产生顺序:p1、p2、p3、p4。第一次执行顺序: p1、p2、p3、p4。 72 | 73 | 其他细节:允许进程在其他进程执行时产生(也就是被当前进程产生)/结束(也就是被当前进程杀死)。 74 | 75 | 3. stride 算法深入 76 | 77 | stride算法原理非常简单,但是有一个比较大的问题。例如两个 pass = 10 的进程,使用 8bit 无符号整形储存 stride, p1.stride = 255, p2.stride = 250,在 p2 执行一个时间片后,理论上下一次应该 p1 执行。 78 | 79 | - 实际情况是轮到 p1 执行吗?为什么? 80 | 81 | 我们之前要求进程优先级 >= 2 其实就是为了解决这个问题。可以证明,**如果不考虑溢出**,在进程优先级全部 >= 2 的情况下,如果严格按照算法执行,那么 STRIDE_MAX – STRIDE_MIN <= BigStride / 2。 82 | 83 | - 为什么?尝试简单说明(传达思想即可,不要求严格证明)。 84 | 85 | 已知以上结论,**在考虑溢出的情况下**,假设我们通过逐个比较得到 Stride 最小的进程,请设计一个合适的比较函数,用来正确比较两个 Stride 的真正大小: 86 | 87 | ```c++ 88 | typedef unsigned long long Stride_t; 89 | const Stride_t BIGSTRIDE = 0xffffffffffffffffULL; 90 | bool Less(Stride_t, Stride_t) { 91 | // ... 92 | } 93 | 94 | ``` 95 | 96 | 例子:假设使用 8 bits 储存 stride, BigStride = 255。那么: 97 | * `Less(125, 255) == false` 98 | * `Less(129, 255) == true` 99 | 100 | ## 报告要求 101 | 102 | * 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) 103 | * 完成问答问题 104 | * (optional) 你对本次实验设计及难度的看法。 105 | 106 | ## 参考信息 107 | 如果有兴趣进一步了解 stride 调度相关内容,可以尝试看看: 108 | 109 | - [作者 Carl A. Waldspurger 写这个调度算法的原论文](https://people.cs.umass.edu/~mcorner/courses/691J/papers/PS/waldspurger_stride/waldspurger95stride.pdf) 110 | - [作者 Carl A. Waldspurger 的博士生答辩slide](http://www.waldspurger.org/carl/papers/phd-mit-slides.pdf) 111 | - [南开大学实验指导中对Stride算法的部分介绍](https://nankai.gitbook.io/ucore-os-on-risc-v64/lab6/tiao-du-suan-fa-kuang-jia#stride-suan-fa) 112 | - [NYU OS课关于Stride Scheduling的Slide](https://cs.nyu.edu/rgrimm/teaching/sp08-os/stride.pdf) 113 | 114 | 如果有兴趣进一步了解用户态线程实现的相关内容,可以尝试看看: 115 | 116 | - [user-multitask in rv64](https://github.com/chyyuu/os_kernel_lab/tree/v4-user-std-multitask) 117 | - [绿色线程 in x86](https://github.com/cfsamson/example-greenthreads) 118 | - [x86版绿色线程的设计实现](https://cfsamson.gitbook.io/green-threads-explained-in-200-lines-of-rust/) 119 | - [用户级多线程的切换原理](https://blog.csdn.net/qq_31601743/article/details/97514081?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-1.control&dist_request_id=&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromMachineLearnPai2-1.control>) -------------------------------------------------------------------------------- /lab3/guide.md: -------------------------------------------------------------------------------- 1 | # 多道程序与分时多任务 2 | 3 | 这一章我们主要进行任务切换,任务的交替执行对于现代计算机是极端重要的,否则一旦开始跑一个程序,你的电脑就会“死机”、“无响应”,这是不能忍受的。 4 | 5 | 具体而言,我们将要完成 [sys_yield]() 系统调用,这样用户程序就可以在空闲的时候暂停执行并把执行权交给其他进程,这就是协作式调度。但是期盼用户程序都是那么的好心是不现实的,我们还将为内核加入时钟中断,同时以时钟中断为节点,不断的在进程间切换,也就是抢占式调度。 6 | 7 | [rust指导书](https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter3/0intro.html#id5) 中对这一章结果很好的展示。 8 | 9 | ## 现场保存与恢复 10 | 11 | 任务切换要求我们对于用户的执行现场进行保存和恢复。欸,现场的保存与恢复?我们似乎已经干过类似的工作!在 lab2 中,我们已经完成了用户现场的保存,就在 `trapframe` 里面,那么是不是说我们只要在返回的时候指定另一个任务的 `trapframe` 就可以直接开始运行另一个任务呢?答案是肯定的!其实 [rCore](https://github.com/rcore-os/rCore) 和 [zCore](https://github.com/rcore-os/zCore) 就是这种设计。但是如果想要这么实现需要更加复杂的 `trapframe` 管理,这里我们采用一种复古的实现方式。 12 | 13 | > 在传统的模型中,每有一个正在运行的用户进程,就要有一个内核线程与之对应,不同进程的最显著区别在于地址空间,也就是页表位置。事实上,在过去的 [ucore 框架](https://github.com/LearningOS/ucore_os_lab) 中,进入内核是不换页表的,内核态和用户态共用页表。但是这样其实会导致一些[侧信道攻击](TODO: google崩了),所以内核与用户态的页表隔离是必须的。 14 | 15 | > 在上述模型中,内核完成进程切换需要完成内核态的切换,我们可以认为不同进程的内核态是执行同一段代码的不同线程,这些线程有不同的页表、内核堆栈、寄存器信息。所以它们之间的切换必须将这三样全部切换。这种切换必须使用汇编完成,否则将十分危险!因为编译器并不能安全的处理这种操作。 16 | 17 | 我们基本上还是使用原有的进程模型,不过由于我们在内核态-用户态切换的时候处理了页表,所以切换时不需要处理 `satp`,但是需要完成寄存器的切换(也就是寄存器的保存和恢复)已经堆栈切换(本质是 `sp` 寄存器的切换)。 18 | 19 | 要在不同内核线程之间切换首先需要有不同的内核线程。在新增的 `proc.h`、`proc.c` 文件中,可以看到进程的定义和初始化。 20 | 21 | ```c 22 | // kernel/trap.h 23 | struct proc { 24 | enum procstate state; // 进程状态 25 | int pid; // 进程ID 26 | uint64 ustack; 27 | uint64 kstack; 28 | struct trapframe *trapframe; 29 | struct context context; // 用于保存进程内核态的寄存器信息,进程切换时使用 30 | }; 31 | ``` 32 | 33 | `struct proc` 一般称作进程控制块,因为它包含了进程几乎所有的信息(例如保存内核栈上的信息可以通过 `trapframe`访问)。 `trapframe` 定义不变,`context`结构体定义如下: 34 | 35 | ```c 36 | // kernel/trap.h 37 | 38 | // Saved registers for kernel context switches. 39 | struct context { 40 | uint64 ra; 41 | uint64 sp; 42 | // callee-saved 43 | uint64 s0; 44 | uint64 s1; 45 | uint64 s2; 46 | uint64 s3; 47 | uint64 s4; 48 | uint64 s5; 49 | uint64 s6; 50 | uint64 s7; 51 | uint64 s8; 52 | uint64 s9; 53 | uint64 s10; 54 | uint64 s11; 55 | }; 56 | ``` 57 | ```c 58 | // kernel/trap.h 59 | enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE }; 60 | ``` 61 | 目前我们只使用这些状态中的一小部分。 62 | 63 | 进程池定义以及进程池初始化如下所示: 64 | 65 | ```c 66 | // kernel/trap.c 67 | struct proc pool[NPROC]; 68 | struct proc idle; // boot proc,始终存在 69 | struct proc* current_proc; // 指示当前进程 70 | 71 | char kstack[NPROC][PAGE_SIZE]; 72 | char ustack[NPROC][PAGE_SIZE]; 73 | char trapframe[NPROC][PAGE_SIZE]; 74 | extern char boot_stack_top[]; // bootstack,用作 idle proc kernel stack 75 | ``` 76 | ```c 77 | // kernel/trap.c 78 | void 79 | procinit(void) 80 | { 81 | struct proc *p; 82 | for(p = pool; p < &pool[NPROC]; p++) { 83 | p->state = UNUSED; 84 | p->kstack = (uint64)kstack[p - pool]; 85 | p->ustack = (uint64)ustack[p - pool]; 86 | p->trapframe = (struct trapframe*)trapframe[p - pool]; 87 | } 88 | idle.kstack = (uint64)boot_stack_top; 89 | idle.pid = 0; 90 | } 91 | ``` 92 | 93 | 被标记为 `UNUSED` 表明进程池该位置没有被使用。 94 | 95 | ## 用户进程加载 96 | 97 | 然后我们来看看用户进程的加载与 lab2 有何异同。加载同样在 `batch.c` 完成。 98 | 99 | ```c 100 | // kernel/batch.c 101 | 102 | int load_app(int n, uint64* info) { 103 | uint64 start = info[n], end = info[n+1], length = end - start; 104 | memset((void*)BASE_ADDRESS + n * MAX_APP_SIZE, 0, MAX_APP_SIZE); 105 | memmove((void*)BASE_ADDRESS + n * MAX_APP_SIZE, (void*)start, length); 106 | return length; 107 | } 108 | 109 | int run_all_app() { 110 | for(int i = 0; i < app_num; ++i) { 111 | struct proc* p = allocproc(); // 分配一个进程控制块 112 | struct trapframe* trapframe = p->trapframe; 113 | printf("run app %d\n", i); 114 | load_app(i, app_info_ptr); 115 | uint64 entry = BASE_ADDRESS + i*MAX_APP_SIZE; 116 | trapframe->epc = entry; 117 | trapframe->sp = (uint64) p->ustack + PAGE_SIZE; 118 | p->state = RUNNABLE; 119 | } 120 | return 0; 121 | } 122 | ``` 123 | 124 | 可以看到,进程 load 的逻辑其实没有变化,但是: 125 | 126 | * 一次性加载所有进程,而不是运行完一个再加载一个。 127 | * 每个进程加载的位置不同,而不是加载于同一位置。 128 | 129 | 这个我们认为设定每个进程所使用的空间是 `[0x80400000 + id*0x20000, 0x80400000 + (id+1)*0x20000)`,每个进程的最大 size 为 0x20000,id 即为进程编号。这个变化是平凡的,因为我们要同时运行多个进程,自然需要所有进程同时存在于内存中。 130 | 131 | > 用户态的进程编译也需要按照这个要求编译,也就是第 i 个程序的起始地址必须为 `0x80400000 + i*0x20000`, 示例代码/测例代码用户态都已经实现这一点。 132 | 133 | `allocproc()` 函数的作用是从进程池中找到一个 `UNUSED` 的进程控制块,进行基本初始化并返回其指针,具体如下: 134 | 135 | ```c 136 | // kernel/proc.c 137 | struct proc* allocproc(void) 138 | { 139 | struct proc *p; 140 | for(p = pool; p < &pool[NPROC]; p++) { 141 | if(p->state == UNUSED) { 142 | goto found; 143 | } 144 | } 145 | return 0; 146 | 147 | found: 148 | p->pid = allocpid(); // 分配一个没有被使用过的 id 149 | p->state = USED; // 标记该控制块被使用 150 | memset(&p->context, 0, sizeof(p->context)); 151 | memset(p->trapframe, 0, PAGE_SIZE); 152 | memset((void*)p->kstack, 0, PAGE_SIZE); 153 | // 初始化第一次运行的上下文信息 154 | p->context.ra = (uint64)usertrapret; 155 | p->context.sp = p->kstack + PAGE_SIZE; 156 | return p; 157 | } 158 | ``` 159 | 160 | 其中,对于 `p->context` 的初始化和 lab2 中我们对于 `trapframe` 的初始化有异曲同工之妙。开始运行一个进程,在这里复用了程序 yield 之后重新开始运行的流程。进程切换的整体流程如下: 161 | 162 | ```c 163 | 164 | 1.程序调用 yield 暂停执行 -> 2.进入内核态 -> 3.内核态切换切到其他进程 -> ... 4.其他进程执行 ... -> 5.其他进程通过相同流程切回到该进程 -> 6.返回用户态 -> 7. 用户态继续执行。 165 | 166 | ``` 167 | 168 | 这其中,`1` `2` `6` `7` 几个步骤都是 lab2 中的标准步骤,进程切换相当于一个特殊的中断处理。而开始运行一个进程从 lab2 中的 `6` `7` 变成了 `5` `6` `7` 。 169 | 170 | 这一章核心的内容就是 `3` `5` 两个新增步骤了,我们来看看这两个步骤的核心流程。 171 | 172 | ## 进程切换核心函数 173 | 174 | 当一个内核线程判断自己要切换出去的时候,它会调用 `sched`函数,并最终通过`swtch` 函数完成切换: 175 | 176 | ```c 177 | // kernel/trap.c 178 | void 179 | sched(void) 180 | { 181 | struct proc *p = curr_proc(); 182 | swtch(&p->context, &idle.context); 183 | } 184 | ``` 185 | 186 | `swtch` 函数的两个参数就是进程控制块的 `context` 结构体的指针,分别为当前进程的和目标进程的。`swtch` 函数设计编译器不能控制的行为,必须汇编实现,**但是编译器还是帮助我们干了一些事情,如保存调用者保存寄存器,设定 ra**。`swtch` 实现如下: 187 | 188 | ```c 189 | # Context switch 190 | # 191 | # void swtch(struct context *old, struct context *new); 192 | # 193 | # Save current registers in old. Load from new. 194 | 195 | 196 | .globl swtch 197 | 198 | # a0 = &old_context, a1 = &new_context 199 | 200 | swtch: 201 | sd ra, 0(a0) # save `ra` 202 | sd sp, 8(a0) # save `sp` 203 | sd s0, 16(a0) 204 | sd s1, 24(a0) 205 | sd s2, 32(a0) 206 | sd s3, 40(a0) 207 | sd s4, 48(a0) 208 | sd s5, 56(a0) 209 | sd s6, 64(a0) 210 | sd s7, 72(a0) 211 | sd s8, 80(a0) 212 | sd s9, 88(a0) 213 | sd s10, 96(a0) 214 | sd s11, 104(a0) 215 | 216 | ld ra, 0(a1) # restore `ra` 217 | ld sp, 8(a1) # restore `sp` 218 | ld s0, 16(a1) 219 | ld s1, 24(a1) 220 | ld s2, 32(a1) 221 | ld s3, 40(a1) 222 | ld s4, 48(a1) 223 | ld s5, 56(a1) 224 | ld s6, 64(a1) 225 | ld s7, 72(a1) 226 | ld s8, 80(a1) 227 | ld s9, 88(a1) 228 | ld s10, 96(a1) 229 | ld s11, 104(a1) 230 | 231 | ret # return to new `ra` 232 | ``` 233 | 234 | 这个函数的汇编实现看似简单,但其实干了一些常规函数绝对不敢干的事情: 235 | 236 | * 改变了 `ra`,使得函数 ret 时,不会返回调用它的函数(这里不妨成为父函数),而是另一个地方(储存在 `0(a1)`中)。事实上,返回的地方是其他因为调用 `swtch` 函数而没有继续执行的父函数。 237 | * 改变了 `sp`,该函数没有任何对堆栈的直接操作,所以直接修改 sp 并没有破坏该函数的执行。 238 | 239 | 该函数成功的切换了: 240 | * 执行流:通过切换 `ra` 241 | * 堆栈:通过切换 `sp` 242 | * 寄存器:通过保存和恢复被调用者保存寄存器。调用者保存寄存器由编译器生成的代码负责保存和恢复。 243 | 244 | 接下来,我们来看看进程切换的整体流程。 245 | 246 | ## 进程切换整体代码框架 247 | 248 | 你可能已经注意到了,示例代码中,`sched` 函数并没有如我们所设想的进程切换模型:找到下一个执行的进程,然后 `swtch` 过去。而是直接切到了 `idle` 进程。那么 `idle` 进程会从那里继续执行呢?思考这个问题之前,我们需要先搞明白,到底是谁开始执行了第一个进程?没错,就是 `idle` 进程,`idle` 进程是第一个进程(boot进程),也是唯一一个永远会存在的进程,它还有一个大家更熟悉的面孔,它就是 os 的 `main` 函数。 249 | 250 | 是时候从头开始梳理从机器 boot 到多个用户进程相互切换到底发生了什么了。 251 | 252 | ```c 253 | void main() { 254 | clean_bss(); // 清空 bss 段 255 | trapinit(); // 开启中断 256 | batchinit(); // 初始化 app_info_ptr 指针 257 | procinit(); // 初始化线程池 258 | // timerinit(); // 开启时钟中断,现在还没有 259 | run_all_app(); // 加载所有用户程序 260 | scheduler(); // 开始调度 261 | } 262 | ``` 263 | 264 | 从 main 函数可以看出,`idle` 线程在完成一系列初始化之后,开始运行 `scheduler` 函数,然后就再也没有回来... 265 | 266 | ```c 267 | void 268 | scheduler(void) 269 | { 270 | struct proc *p; 271 | for(;;){ 272 | for(p = pool; p < &pool[NPROC]; p++) { 273 | if(p->state == RUNNABLE) { 274 | p->state = RUNNING; 275 | current_proc = p; 276 | swtch(&idle.context, &p->context); 277 | } 278 | } 279 | } 280 | } 281 | ``` 282 | 283 | 可以看到 `idle` 线程死循环在了一件事情上:寻找一个 `RUNNABLE` 的进程,然后切换到它开始执行。当这个进程调用 `sched` 后,执行流会回到 `idle` 线程,然后继续开始寻找,如此往复。直到所有进程执行完毕,在 sys_exit 系统调用中有统计计数,一旦 exit 的进程达到用户程序数量就关机。 284 | 285 | 也就是说,所有进程间切换都需要通过 `idle` 中转一下。那么可不可以一步到位呢?答案是肯定的,其实 [rust版代码](https://github.com/rcore-os/rCore-Tutorial-v3) 就是采取这种实现:在一个进程退出时,直接寻找下一个就绪进程,然后直接切换过去,没有 idle 的中转。两种实现都是可行的。 286 | 287 | 在了解这些之后,我们就可以实现协作式调度了,主要是 `sys_yeild` 系统调用,其实现十分简单,请同学们自行查看 `kernel/syscall.c`。 288 | 289 | ## 时钟中断与抢占式调度 290 | 291 | 没一个程序员都应该干过的一件事情就是提交一个死循环,看看系统会不会真的被卡死。目前,我们虽然有了基于 `sys_yield` 的协作式调度,但只要用户进程不愿意放弃执行权,我们的 os 是没有办法切换到其他进程的。这样的 os 低效且不公平,因此我们需要强制的进程切换手段,这就需要时钟中断的介入。 292 | 293 | `timer.c` 中包含了相关函数,功能分别为:打开了时钟中断使能,设置下一次中断间隔,读取当前的机器 cycle 数: 294 | 295 | ```c 296 | // kernel/timer.c 297 | 298 | /// Enable timer interrupt 299 | void timerinit() { 300 | // Enable supervisor timer interrupt 301 | w_sie(r_sie() | SIE_STIE); 302 | set_next_timer(); 303 | } 304 | /// Set the next timer interrupt 305 | void set_next_timer() { 306 | uint64 timebase = 125000; 307 | set_timer(get_cycle() + timebase); 308 | } 309 | 310 | uint64 get_cycle() { 311 | return r_time(); 312 | } 313 | ``` 314 | 315 | qemu 模拟的时钟频率大致为 12_500_000Hz(未找到官方数据,属于个人测算),这里我们选择的时钟中断间隔为 10ms。 316 | 317 | 利用 `get_cycle` 函数还可以实现 gettime 函数(注意测例要求的接口),原理比较简单不做赘述。 318 | 319 | 那么时钟中断如何处理呢?按照设计,需要在发生时钟中断时干两件事:设置下一次时钟中断和切换当前进程。相关逻辑在 `kernel/trap.c` 中: 320 | 321 | ```c 322 | void usertrap() { 323 | // ... 324 | uint64 cause = r_scause(); 325 | if(cause & (1ULL << 63)) { 326 | cause &= ~(1ULL << 63); 327 | switch(cause) { 328 | case SupervisorTimer: 329 | set_next_timer(); 330 | yield(); 331 | break; 332 | default: 333 | unknown_trap(); 334 | break; 335 | } 336 | } else { 337 | // .... 338 | } 339 | usertrapret(); 340 | } 341 | ``` 342 | 343 | 按照 riscv 标准,通过 `scause` 最高位区分中断与异常,然后再细分处理。 344 | 345 | ## 其他 346 | 347 | 目前,如果内核发生异常,比如访问非法指令、时钟中断,我们是不处理的(以后可能会处理),这可以从 `kernel_trap` 的设计中看出: 348 | 349 | ```c 350 | void kerneltrap() { 351 | if((r_sstatus() & SSTATUS_SPP) == 0) 352 | panic("kerneltrap: not from supervisor mode"); 353 | panic("trap from kernel\n"); 354 | } 355 | 356 | void set_kerneltrap(void) { 357 | w_stvec((uint64)kerneltrap & ~0x3); // DIRECT 358 | } 359 | ``` 360 | 361 | 一旦从用户态进入内核态,我们就改变 `stvec`,防止内核中断错误的跳转到用户中断处理例程。在返回用户态时切换回来: 362 | 363 | ```c 364 | void usertrap() { 365 | set_kerneltrap(); 366 | // ... 367 | } 368 | 369 | void usertrapret() { 370 | set_usertrap(); 371 | // ... 372 | } 373 | ``` 374 | 375 | 最后,进程结束的 `sys_exit` 系统调用需要调整: 376 | 377 | ```c 378 | void exit(int code) { 379 | struct proc *p = curr_proc(); 380 | p->state = UNUSED; // 空出进程池位置 381 | sched(); // 运行下一个进程 382 | } 383 | ``` 384 | 385 | `main` 函数需要进行新添加的初始化: 386 | 387 | ```c 388 | void main() { 389 | clean_bss(); 390 | trapinit(); 391 | batchinit(); 392 | procinit(); 393 | timerinit(); // 增加时钟初始化 394 | run_all_app(); 395 | printf("start scheduler!\n"); 396 | scheduler(); 397 | } 398 | ``` 399 | 400 | ## 展望 401 | 402 | 下一节就是页表了,页表极大的方便了用户态的开发,比如我们终于不用在一 0x80400000 这个奇怪的人为地址了,从 lab4 开始,所有的用户程序将 从 0x1000 开始,虽然这也是人为规定的,但比 0x80400000 要舒服很多。 403 | 404 | 但是,困难往往是不会消失的,那么用户态的困难转嫁到哪里去了呢?准备好迎接硬骨头吧! 405 | -------------------------------------------------------------------------------- /lab4/exercise.md: -------------------------------------------------------------------------------- 1 | # lab4 实验要求 2 | 3 | - 本节难度: **看懂代码就和lab1一样** 4 | 5 | ## 编程作业 6 | 7 | ### 申请内存 8 | 9 | 你有没有想过,当你在 C 语言中写下的 `new int[100];` 执行时可能会发生哪些事情?你可能已经发现,目前我们给用户程序的内存都是固定的并没有增长的能力,这些程序是不能执行 `new` 这类导致内存使用增加的操作。libc 中通过 [sbrk](https://linux.die.net/man/2/sbrk) 系统调用增加进程可使用的堆空间,这也是本来的题目设计,但是一位热心的往年助教J学长表示:这一点也不酷!他推荐了另一个申请内存的系统调用。 10 | 11 | [mmap](https://man7.org/linux/man-pages/man2/mmap.2.html) 本身主要使用来在内存中映射文件的,这里我们简化它的功能,仅仅用来提供申请内存的功能。 12 | 13 | mmap 系统调用新定义: 14 | - syscall ID:222 15 | - C接口:`int mmap(void* start, unsigned long long len, int port)` 16 | - Rust接口:`fn mmap(start: usize, len: usize, port: usize) -> i32` 17 | - 功能:申请长度为 len 字节的物理内存(不要求实际物理内存位置,可以随便找一块),并映射到 addr 开始的虚存,内存页属性为 port。 18 | - 参数: 19 | - start 需要映射的虚存起始地址。 20 | - len:映射字节长度,可以为0(如果是则直接返回),不可过大(上限1G)。0位表示是否可读,第1位表示是否可写,第2位表示是否可执行。其他位无效(必须为0)。 21 | - 说明: 22 | - 正确时返回实际 map size(为 4096 的倍数),错误返回-1。 23 | - 为了简单,addr 要求按页对其(否则报错),len 可直接按页取上整。 24 | - 为了简单,不考虑分配失败时的页回收(也就是内存泄漏)。 25 | - 错误: 26 | - [addr, addr + len] 存在已经被映射的页。 27 | - 物理内存不足。 28 | - port & ~0x7 != 0 (port 其余位必须为0)。 29 | - port & 0x7 = 0 (这样的内存无意义)。 30 | 31 | munmap 32 | - syscall ID:215 33 | - C接口:`int mumap(void* start, unsigned long long len)` 34 | - Rust接口:`fn mumap(start: usize, len: usize) -> i32` 35 | - 功能:取消一块虚存的映射。 36 | - 参数:同 mmap 37 | - 说明: 38 | - 为了简单,参数错误时不考虑内存的恢复和回收。 39 | - 错误: 40 | - [start, start + len) 中存在未被映射的虚存。 41 | 42 | ### 实验要求 43 | 44 | - 实现分支:ch4。 45 | - 完成实验指导书中的内容,实现虚拟内存,可以运行过去几个lab的程序。 46 | - 更新 sys_write 的范围检查,改为基于页表的检查方法。 47 | - 实现 mmap 和 munmap 两个自定义系统调用,并通过 [C测例](https://github.com/DeathWish5/riscvos-c-tests)中chapter4对应的所有测例。 48 | 49 | 注意:记得删除 lab3 关于程序时间片上界的规定。 50 | 51 | challenge: 支持多核。 52 | 53 | ### 实验检查 54 | 55 | - 实验目录要求 56 | 57 | 目录要求不变(参考lab1目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。 58 | 59 | 加载的用户测例位置: `../user/target/bin`。 60 | 61 | - 检查 62 | 63 | 可以正确 `make run` 执行,可以正确执行目标用户测例,并得到预期输出(详见测例注释)。 64 | 65 | ## 问答作业 66 | 67 | 1. 请列举 SV39 页表页表项的组成,结合课堂内容,描述其中的标志位有何作用/潜在作用? 68 | 69 | 2. 缺页 70 | 71 | 这次的实验没有涉及到缺页有点遗憾,主要是缺页难以测试,而且更多的是一种优化,不符合这次实验的核心理念,所以这里补两道小题。 72 | 73 | 缺页指的是进程访问页面时页面不在页表中或在页表中无效的现象,此时 MMU 将会返回一个中断,告知 os 进程内存访问出了问题。os 选择填补页表并重新执行异常指令或者杀死进程。 74 | 75 | - 请问哪些异常可能是缺页导致的? 76 | - 发生缺页时,描述相关的重要寄存器的值(lab2中描述过的可以简单点)。 77 | 78 | 缺页有很多可能原因,其中之一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 os 并不会马上这样做,而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。 79 | 80 | - 这样做有哪些好处? 81 | 82 | 此外 COW(Copy On Write) 也是常见的容易导致缺页的 Lazy 策略,这个之后再说。其实,我们的 mmap 也可以采取 Lazy 策略,比如:一个用户进程先后申请了 10G 的内存空间,然后用了其中 1M 就直接退出了。按照现在的做法,我们显然亏大了,进行了很多没有意义的页表操作。 83 | 84 | - 请问处理 10G 连续的内存页面,需要操作的页表实际大致占用多少内存(给出数量级即可)? 85 | - 请简单思考如何才能在现有框架基础上实现 Lazy 策略,缺页时又如何处理?描述合理即可,不需要考虑实现。 86 | 87 | 缺页的另一个常见原因是 swap 策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效。 88 | 89 | - 此时页面失效如何表现在页表项(PTE)上? 90 | 91 | 3. 双页表与单页表 92 | 93 | 为了防范侧信道攻击,我们的 os 使用了双页表。但是传统的设计一直是单页表的,也就是说,用户线程和对应的内核线程共用同一张页表,只不过内核对应的地址只允许在内核态访问。(备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见[KPTI](https://en.wikipedia.org/wiki/Kernel_page-table_isolation>) ) 94 | 95 | - 如何更换页表? 96 | - 单页表情况下,如何控制用户态无法访问内核页面?(tips:看看上一题最后一问) 97 | - 单页表有何优势?(回答合理即可) 98 | - 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择合适更换页表(回答合理即可)? 99 | 100 | ## 报告要求 101 | 102 | * 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) 103 | * 完成问答问题 104 | * (optional) 你对本次实验设计及难度的看法。 105 | 106 | -------------------------------------------------------------------------------- /lab4/guide.md: -------------------------------------------------------------------------------- 1 | # 物理内存管理与虚实映射 2 | 3 | 这一章我们会首先把物理内存更加科学的管理起来,然后激活页表,启用虚存机制,这样我们就终于不用 0x80400000 这些奇奇怪怪的地址和 lab3 烦人的地址等差数列了。 4 | 5 | ## 物理内存管理:kalloc 6 | 7 | 出于简单考虑,我们使用链表来管理内存: 8 | 9 | ```c 10 | // kernel/kalloc.c 11 | struct linklist { 12 | struct linklist *next; 13 | }; 14 | 15 | struct { 16 | struct linklist *freelist; 17 | } kmem; 18 | ``` 19 | 20 | 我们使用一个链表来维护当前空闲的页面,当申请一个页时,将链表头分配出去,当一个页被释放时,加到链表头。注意,我们的管理仅仅在页这个粒度进行,所以所有的地址必须是 PAGE_SIZE 对齐的。 21 | 22 | ```c 23 | // kernel/kalloc.c: 页面分配 24 | void * 25 | kalloc(void) 26 | { 27 | struct linklist *l; 28 | l = kmem.freelist; 29 | kmem.freelist = l->next; 30 | return (void*)l; 31 | } 32 | 33 | // kernel/kalloc.c: 页面释放 34 | void * 35 | kfree(void *pa) 36 | { 37 | struct linklist *l; 38 | l = (struct linklist*)pa; 39 | l->next = kmem.freelist; 40 | kmem.freelist = l; 41 | } 42 | ``` 43 | 44 | 可能你会疑惑这个链表为啥和常见的长得不一样,为啥只有指针,没有数据,那有啥用?其实,这里链表指针本身就蕴含了数据,由于我们管理的是空闲页面,所以其实我们可以在这些页面中储存一些东西,不会破坏数据。所以,我们在空闲页面的开头存了一个指针,指向下一个空闲页面地址。 45 | 46 | 那么我们的内核有那些空闲内存需要管理呢?事实上,qemu 已经规定了内核需要管理的内存范围,可以参考[这里](https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter1/3-2-mini-rt-baremetal.html#id4),具体来说,需要软件管理的内存为 [0x80000000, 0x88000000),其中,rustsbi 使用了 [0x80000000, 0x80200000) 的范围,其余都是内核使用,来看看 `kmem` 的初始化: 47 | 48 | ```c 49 | // kernel/kalloc.c 50 | 51 | // 物理内存管理初始化 52 | // ekernel 为链接脚本定义的内核代码结束地址,PHYSTOP = 0x88000000 53 | void 54 | kinit() 55 | { 56 | freerange(ekernel, (void*)PHYSTOP); 57 | } 58 | 59 | // kfree [pa_start, pa_end) 60 | void 61 | freerange(void *pa_start, void *pa_end) 62 | { 63 | char *p; 64 | p = (char*)PGROUNDUP((uint64)pa_start); 65 | for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) 66 | kfree(p); 67 | } 68 | ``` 69 | 70 | 可见,我们把内核暂时没有用到的 `ekernel` 到 0x88000000 的范围 `kfree` 掉,也就是加入了空闲内存链表。这十分简单合理。以后,我们需要一个新的内存页面时,不会采用预分配的方案,也不会直接指定一个地址就开始用,而是使用 `kalloc` 获得一个空闲页,使用完成后通过 `kfree` 释放。这就完成了物理内存的管理,页式管理很简单对吧! 71 | 72 | ## RV39 页表 73 | 74 | 这部分理论知识请参见[RV39页表](https://rcore-os.github.io/rCore-Tutorial-Book-v3/chapter4/3sv39-implementation-1.html),可直接跳过其中语言相关的内容。 75 | 76 | 在示例代码中,对于页表的操作位于 `kernel/vm.c` 文件,主要接口有: 77 | 78 | ```c 79 | // kernel/vm.c 80 | 81 | // Return the address of the PTE in page table pagetable 82 | // that corresponds to virtual address va. If alloc!=0, 83 | // create any required page-table pages. 84 | // 85 | // The risc-v Sv39 scheme has three levels of page-table 86 | // pages. A page-table page contains 512 64-bit PTEs. 87 | // A 64-bit virtual address is split into five fields: 88 | // 39..63 -- must be zero. 89 | // 30..38 -- 9 bits of level-2 index. 90 | // 21..29 -- 9 bits of level-1 index. 91 | // 12..20 -- 9 bits of level-0 index. 92 | // 0..11 -- 12 bits of byte offset within the page. 93 | pte_t* walk(pagetable_t pagetable, uint64 va, int alloc); 94 | 95 | // Look up a virtual address, return the physical page, 96 | // or 0 if not mapped. 97 | // Can only be used to look up user pages. 98 | // Use `walk` 99 | uint64 walkaddr(pagetable_t pagetable, uint64 va); 100 | 101 | // Look up a virtual address, return the physical address. 102 | // Use `walkaddr` 103 | uint64 useraddr(pagetable_t pagetable, uint64 va); 104 | 105 | ``` 106 | 107 | 这三个是查页表相关函数,`useraddr` 将 `pagetable` 页表下的虚拟地址 `va` 转化为物理地址,`walkaddr`、`walk` 是更底层的函数。 108 | 109 | 以下是建立新映射和取消映射的函数,`mappages` 在 `pagetable` 中建立 `[va, va + size)` 到 `[pa, pa + size)` 的映射,页表属性为`perm`,`uvmunmap` 则取消一段映射,`do_free` 控制是否 `kfree` 对应的物理内存(比如这是一个共享内存,那么第一次 unmap 就不 free,最后一个 unmap 肯定要 free)。 110 | 111 | ```c 112 | // Create PTEs for virtual addresses starting at va that refer to 113 | // physical addresses starting at pa. va and size might not 114 | // be page-aligned. Returns 0 on success, -1 if walk() couldn't 115 | // allocate a needed page-table page. 116 | int mappages(pagetable_t pagetable, uint64 va, uint64 size, uint64 pa, int perm); 117 | 118 | // Remove npages of mappings starting from va. va must be 119 | // page-aligned. The mappings must exist. 120 | // Optionally free the physical memory. 121 | void uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free); 122 | ``` 123 | 124 | 然后有三个实用的跨页表操作函数: 125 | 126 | ```c 127 | // Copy from kernel to user. 128 | // Copy len bytes from src to virtual address dstva in a given page table. 129 | // Return 0 on success, -1 on error. 130 | int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len); 131 | 132 | // Copy from user to kernel. 133 | // Copy len bytes to dst from virtual address srcva in a given page table. 134 | // Return 0 on success, -1 on error. 135 | int copyin(pagetable_t pagetable, char *dst, uint64 srcva, uint64 len); 136 | 137 | // Copy a null-terminated string from user to kernel. 138 | // Copy bytes to dst from virtual address srcva in a given page table, 139 | // until a '\0', or max. 140 | // Return 0 on success, -1 on error. 141 | int copyinstr(pagetable_t pagetable, char *dst, uint64 srcva, uint64 max); 142 | ``` 143 | 用于与指定页表进行数据交换,`copyout` 可以向页表中写东西,后续用于 `sys_read`,也就是给用户传数据,`copyin` 用户接受用户的 buffer,也就是从用户哪里读数据。**注意**,用户在启用了虚拟内存之后,用户 syscall 给出的指针是不能直接用的,因为与内核的映射不一样,会读取错误的物理地址,使用指针必须通过 `useraddr` 转化,当然,更加推荐的是 `copyin/out` 接口,否则很可能损坏内存数据,同时,`copyin/out` 接口处理了虚存跨页的情况,`useraddr` 则需要手动判断并处理。 144 | 145 | > 注意虚拟内存上的连续页面,物理内存(或者说内核视角)不一定是连续的,所以如果传递数据碰到跨页现象,必须特判处理!否则很容易导致读写错误内存,无线玄学 bug! 用户 buffer 要么使用 copyin/out 接口,要么一定要判断跨页! 146 | 147 | 相关数据类型定义如下: 148 | 149 | ```c 150 | typedef uint64 pte_t; 151 | typedef uint64* pagetable_t;// 512 PTEs 152 | ``` 153 | 154 | ## 内核页表 155 | 156 | 知道了页表操作相关函数,那么就来看看具体建立页表的过程吧,首先是内核页表,对应函数也在 `vm.c`。一般来说,由于内核有频繁的操作不同进程内存的需求,内核需要能够方便的访问所有的物理内存,所以内核映射往往十分简单,很多时候往往是线性映射,也就是内核 `va = pa + KERNEL_OFFSET`,在该示例代码中,为了更加的简单,取 `KERNEL_OFFSET = 0`,也就是内核虚拟地址完全等于实际物理地址。内核页表建立过程如下: 157 | 158 | ```c 159 | // kernel/vm.c 160 | 161 | // 页表项定义如下: 162 | #define PTE_V (1L << 0) // valid 163 | #define PTE_R (1L << 1) 164 | #define PTE_W (1L << 2) 165 | #define PTE_X (1L << 3) 166 | #define PTE_U (1L << 4) // 1 -> user can access 167 | 168 | #define KERNBASE (0x80200000) 169 | extern char e_text[]; // kernel.ld sets this to end of kernel code. 170 | extern char trampoline[]; 171 | 172 | pagetable_t kvmmake(void) { 173 | pagetable_t kpgtbl; 174 | kpgtbl = (pagetable_t) kalloc(); 175 | memset(kpgtbl, 0, PGSIZE); 176 | mappages(kpgtbl, KERNBASE, KERNBASE, (uint64) e_text - KERNBASE, PTE_R | PTE_X); 177 | mappages(kpgtbl, (uint64) e_text, (uint64) e_text, PHYSTOP - (uint64) e_text, PTE_R | PTE_W); 178 | mappages(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X); 179 | return kpgtbl; 180 | } 181 | ``` 182 | 183 | 其中,内核页表地址随机取一个页就可以。内核映射有三段,一段为代码段:[KERNBASE, e_text),可读可执行(注意页表项)。一段为数据段:[e_text, 0x88000000),可读可写。关于 `TRAMPOLINE` 段含义稍后解释。 184 | 185 | 激活页表机制在 `kinit` 函数: 186 | 187 | ```c 188 | // kernel/vm.c 189 | 190 | void kvminit(void) { 191 | kernel_pagetable = kvmmake(); 192 | w_satp(MAKE_SATP(kernel_pagetable)); // 写 satp 寄存器,正式激活页表机制 193 | sfence_vma(); // 更换页表,必须刷新 TLB 194 | } 195 | ``` 196 | 197 | ## 用户地址加载 198 | 199 | 用户的加载逻辑在 `loader.c` 中(也就是原来的 `batch.c`,改名了),其中唯一逻辑变化较大的就是 `bin_loader` 函数: 200 | 201 | ```c 202 | // kernel/vm.c 203 | 204 | // kernel/loader.c 205 | pagetable_t bin_loader(uint64 start, uint64 end, struct proc *p) { 206 | pagetable_t pg = (pagetable_t) kalloc(); 207 | memset(pg, 0, PGSIZE); 208 | // trampoline 就是 uservec userret 两个函数的位置 209 | mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X) < 0); 210 | // trapframe 之前是预分配的,现在我们用 kalloc 得到。 211 | p->trapframe = (struct trapframe*)kalloc(); 212 | memset(p->trapframe, 0, PGSIZE); 213 | // map trapframe,位置稍后解释 214 | mappages(pg, TRAPFRAME, PGSIZE, (uint64)p->trapframe, PTE_R | PTE_W); 215 | // 这部分就是 bin 程序的实际 map, 我们把 [BASE_ADDRESS, APP_SIZE) map 到 [app_start, app_end) 216 | // 替代了之前的拷贝 217 | uint64 s = PGROUNDDOWN(start), e = PGROUNDUP(end); 218 | if (mappages(pg, BASE_ADDRESS, e - s, s, PTE_U | PTE_R | PTE_W | PTE_X) != 0) { 219 | panic("wrong loader 1\n"); 220 | } 221 | p->pagetable = pg; 222 | p->trapframe->epc = BASE_ADDRESS; 223 | // map user stack 224 | mappages(pg, USTACK_BOTTOM, USTACK_SIZE, (uint64) kalloc(), PTE_U | PTE_R | PTE_W | PTE_X); 225 | p->ustack = USTACK_BOTTOM; 226 | p->trapframe->sp = p->ustack + USTACK_SIZE; 227 | return pg; 228 | } 229 | ``` 230 | 231 | 其中 `trapframe` 和 `trampoline`代码(也就是 `userret`、`uservec`函数)比较特殊,这两块内存用户特权级切换,必须用户态和内核态都能访问。所以它们在内核和用户页表中都有 map,注意所有 kalloc() 分配的内存内核都能访问。他们设定的虚拟地址为: 232 | 233 | ```c 234 | // one beyond the highest possible virtual address. 235 | // MAXVA is actually one bit less than the max allowed by 236 | // Sv39, to avoid having to sign-extend virtual addresses 237 | // that have the high bit set. 238 | #define MAXVA (1L << (9 + 9 + 9 + 12 - 1)) 239 | 240 | #define USER_TOP (MAXVA) 241 | 242 | #define TRAMPOLINE (USER_TOP - PGSIZE) 243 | 244 | #define TRAPFRAME (TRAMPOLINE - PGSIZE) 245 | ``` 246 | 247 | 这与为何要这么设定,留给读者思考(不是很关键,感兴趣可以在群里讨论或者直接找助教)。 248 | 249 | `bin_loader` 主要变化就是利用 map 代替了原来的拷贝,这可以节约时间和内存,而且 `BASE_ADDRESS` 可以设置为 0x1000 这个看起来更统一和舒服的地址,不再是 `0x80400000`。 250 | 251 | ## 其他 252 | 253 | 由于采用了原地映射(也就是 `KERNEL_OFFSET = 0`),内核大部分代码不需要调整,除了 `trampoline` 中的 `userret` 函数,需要调整 `trap.c` 中的 `userrettrap` 函数最后几行跳转到 `userret` 的逻辑: 254 | 255 | ```c 256 | // kernel/trap.c 257 | 258 | void usertrapret() { 259 | // ... 260 | 261 | // tell trampoline.S the user page table to switch to. 262 | uint64 satp = MAKE_SATP(curr_proc()->pagetable); 263 | uint64 fn = TRAMPOLINE + (userret - trampoline); 264 | ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); 265 | } 266 | ``` 267 | 268 | `vm.c` 等文件中还新增了一些函数,这些函数在 lab4 不是很重要,在 lab5 我们会对他们进行了解。也就是说,lab5 会对内存模型进行一定的调整,之后我们的内存模型就稳定了。 269 | 270 | ## 展望 271 | 272 | 习题课上话说太满了。。。。好像 lab4 也不是很难的样子哈 (O.O),不过好像 rust 很难的样子诶(偷笑。下一章主要是 fork 和 exec,就酱紫。 -------------------------------------------------------------------------------- /lab5/exercise.md: -------------------------------------------------------------------------------- 1 | # lab5 实验要求 2 | 3 | - 本节难度: **一定比lab4简单** 4 | 5 | ## 编程作业 6 | 7 | ### 进程创建 8 | 9 | 大家一定好奇过为啥进程创建要用 fork + execve 这么一个奇怪的系统调用,就不能直接搞一个新进程吗?思而不学则殆,我们就来试一试!这章的编程练习请大家实现一个完全 DIY 的系统调用 spawn,用以创建一个新进程。 10 | 11 | spawn ([标准spawn看这里](https://man7.org/linux/man-pages/man3/posix_spawn.3.html>)) 12 | - syscall ID: 400 13 | - C 接口:`int spawn(char *filename)` 14 | - Rust 接口:`fn spawn(file: *const u8) -> isize` 15 | - 功能:相当于 fork + exec,新建子进程并执行目标程序。 16 | - 说明:成功返回子进程id,否则返回 -1。 17 | - 可能的错误: 18 | - 无效的文件名 19 | - 进程池满等资源错误。 20 | 21 | ### 实验要求 22 | 23 | - 实现分支:ch5。 24 | - 完成实验指导书中的内容,实现进程控制,可以运行 usershell。 25 | - 实现自定义系统调用 spawn,并通过 [C测例](https://github.com/DeathWish5/riscvos-c-tests)中chapter5对应的所有测例。 26 | 27 | challenge: 支持多核。 28 | 29 | ### 实验检查 30 | 31 | - 实验目录要求 32 | 33 | 目录要求不变(参考lab1目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。 34 | 35 | 加载的用户测例位置: `../user/target/bin`。 36 | 37 | - 检查 38 | 39 | 可以正确 `make run` 执行,可以正确执行目标用户测例,并得到预期输出(详见测例注释)。 40 | 41 | ## 问答作业 42 | 43 | 1. fork + exec 的一个比较大的问题是 fork 之后的内存页/文件等资源完全没有使用就废弃了,针对这一点,有什么改进策略? 44 | 45 | 2. 其实使用了题1的策略之后,fork + exec 所带来的无效资源的问题已经基本被解决了,但是近年来 fork 还是在被不断的批判,那么到底是什么正在"杀死"fork?可以参考[论文](https://www.microsoft.com/en-us/research/uploads/prod/2019/04/fork-hotos19.pdf),**注意**:回答无明显错误就给满分,出这题只是想引发大家的思考,完全不要求看论文,球球了,别卷了。 46 | 47 | 3. fork 当年被设计并称道肯定是有其好处的。请使用**带初始参数**的 spawn 重写如下 fork 程序,然后描述 fork 有那些好处。注意:使用"伪代码"传达意思即可,spawn接口可以自定义。可以写多个文件。 48 | 49 | ```c 50 | int main() { 51 | int a = get_a(); 52 | if(fork() == 0) { 53 | int b = get_b(); 54 | printf("a + b = %d", a + b); 55 | exit(0); 56 | } 57 | printf("a = %d", a); 58 | return 0; 59 | } 60 | 61 | ``` 62 | 63 | 4. 描述进程执行的几种状态,以及 fork/exec/wait/exit 对与状态的影响。 64 | 65 | ## 报告要求 66 | 67 | * 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) 68 | * 完成问答问题 69 | * (optional) 你对本次实验设计及难度的看法。 70 | -------------------------------------------------------------------------------- /lab5/guide.md: -------------------------------------------------------------------------------- 1 | # 进程及进程管理 2 | 3 | 这一章中,我们将实现更加灵活的进程控制,具体来说,我们将实现如下四个系统调用: 4 | 5 | * sys_read(int fd, char* buf, int size): 从标准输入读取若干个字节。 6 | * sys_fork(): 创建一个与当前进程几乎完全一致的进程。 7 | * sys_exec(char* filename): 修改当前进程,使其从头开始执行指定程序。 8 | * sys_wait(int pid, int* exit_code): 等待某一个或者任意一个子进程结束,获取其 exit_code。 9 | 10 | ## usershell 11 | 12 | 在分析内核实现之前,我们先来看看完成这一章的内容之后,我们能干什么新奇的事情。最炫酷的是,我们可以运行 shell 了! 13 | 14 | 看看我们的 `loader.c` 里加载进程的逻辑有那些变化: 15 | 16 | ```c 17 | // kernel/loader.c 18 | int run_all_app() { 19 | struct proc *p = allocproc(); 20 | p->parent = 0; 21 | int id = get_id_by_name("user_shell"); 22 | if(id < 0) 23 | panic("no user shell"); 24 | loader(id, p); 25 | p->state = RUNNABLE; 26 | return 0; 27 | } 28 | ``` 29 | 30 | 可以看到,虽然函数名称没有变,但是这个 `run_all_app` 函数和以往干的事情完全不一样!它仅仅加载了一个程序,那就是 `user_shell`,而 `user_shell` 运行之后会这样: 31 | 32 | ![](shell.png) 33 | 34 | shell 就是一个极度简化的标准 console,它会死循环的等待用户输入一个代表程序名称的字符串(通过`sys_read`),当用户按下空格之后,shell 会使用 `fork` 和 `exec` 创建并执行这个程序,然后通过 `sys_wait` 来等待程序执行结束,并输出 `exit_code`。有了 shell 之后,我们可以只执行自己希望的程序,也可以执行某一个程序很多次来观察输出,这对于使用体验是极大的提升!可以说,第五章的所有努力都是为了支持 shell。 35 | 36 | ## fork 37 | 38 | fork 是 unix 世界中创建新进程的标准方法,也是唯一的方法。fork 显然是实现 shell 的核心 syscall。 39 | 40 | 那么 fork 需要做那些事情呢? 41 | 42 | 首先,由于要创建一个新的进程,我们必须申请一个新的进程控制块。事实上,我们的最终目标也就是一个新的进程控制块。为了支持进程控制,进程控制块也进行了一些修改: 43 | 44 | ```diff 45 | // kernel/proc.c 46 | struct proc { 47 | enum procstate state; 48 | int pid; 49 | pagetable_t pagetable; 50 | uint64 ustack; 51 | uint64 kstack; 52 | struct trapframe *trapframe; 53 | struct context context; 54 | + uint64 sz; // Memory size 55 | + struct proc *parent; // Parent process 56 | uint64 exit_code; 57 | }; 58 | ``` 59 | 60 | 申请新的进程控制块之后,我们还需要对其完成最基本的初始化。包括: `stata`、`pid`、`kstack`、`context` 几项(这几项的初始化和第三、四章加载新进程时一样)。 61 | 62 | 由于要和父进程一模一样,其他项就需要拷贝父进程的了,其中 `trapframe` 可以直接拷贝: 63 | 64 | ```c 65 | *(child->trapframe) = *(parent->trapframe) 66 | ``` 67 | 68 | 对页表的拷贝就比较复杂了,因为我们不能仅仅拷贝一份一模一样的页表,那么父子进程就会修改同样的物理内存,发生数据冲突,不符合进程隔离的要求。需要把页表对应的页先拷贝一份,然后建立一个对这些新页有同样映射的页表。这一工作由一个 `uvmcopy` 的函数去做。 69 | 70 | ```c 71 | uvmcopy(p->pagetable, np->pagetable, p->sz); 72 | ``` 73 | 74 | 还记得我们在第四章设定的用户内存模型吗?它非常的简单,仅仅是从 0x0 开始的一段连续内存,所以这里可以偷懒不去便利整个页表。直接拷贝 [0x0, memory size) 的内存,memory size 也就是 `proc->sz`,进程加载的时候会计算得到。 75 | 76 | 重要提醒! 77 | > 这种实现在目前的框架下不会导致问题,但是如果把 lab4 的 mmap 也考虑到的话,fork 就会错误。测例中严格避免了 mmap 和 fork 同时出现的情况。 78 | 79 | 最后,fork 要求子进程的返回值是 0,所以需要修改 `trapframe->a0`,同时需要设定父子关系。最终,fork 的实现长这个样子: 80 | 81 | ```c 82 | // kernel/proc.c 83 | int 84 | fork(void) 85 | { 86 | struct proc *np; 87 | struct proc *p = curr_proc(); 88 | 89 | // Allocate process. 90 | if((np = allocproc()) == 0){ 91 | panic("allocproc\n"); 92 | } 93 | np->state = RUNNABLE; 94 | 95 | // copy saved user registers. 96 | *(np->trapframe) = *(p->trapframe); 97 | 98 | // Copy user memory from parent to child. 99 | if(uvmcopy(p->pagetable, np->pagetable, p->sz) < 0){ 100 | panic("uvmcopy\n"); 101 | } 102 | np->sz = p->sz; 103 | 104 | // Cause fork to return 0 in the child. 105 | np->trapframe->a0 = 0; 106 | 107 | np->parent = p; 108 | 109 | return np->pid; 110 | } 111 | ``` 112 | 113 | `uvmcopy` 的实现见 `vm.c`,虽然是 fork 的核心实现,但并不复杂,不做赘述。 114 | 115 | 值得一提的是,在这种实现下,父进程会马上返回,而子进程会进入调度队列,等待调度到的时候执行。 116 | 117 | ## exec 118 | 119 | exec 要执行一个新的进程,它要干的事情和 `bin_loader` 是很相似的。事实上,不同点在于,exec 需要先清理并回收掉当前进程占用的资源,目前只有内存。 120 | 121 | 内存的回收通过如下函数完成: 122 | 123 | ```c 124 | void 125 | proc_freepagetable(pagetable_t pagetable, uint64 sz) 126 | { 127 | uvmunmap(pagetable, TRAMPOLINE, 1, 0); 128 | uvmunmap(pagetable, TRAPFRAME, 1, 0); 129 | uvmfree(pagetable, sz); 130 | } 131 | ``` 132 | 133 | 需要注意的是,由于 `trapframe` 和 `trampoline` 是可以复用的(每个进程都一样),所以我们并不会把他们删掉,而仅仅是 unmap。而对于用户真正的数据,就会删掉映射的同时把物理页面也 free 掉。 134 | 135 | > 其实 `trapframe` 和 `trampoline` 也可以不 unmap 直接用,但我们想复用 loader.c 中的代码,所以先 unmap 掉。 136 | 137 | 最后,我们的 exec 长这样: 138 | 139 | ```c 140 | int exec(char* name) { 141 | struct proc *p = curr_proc(); 142 | proc_freepagetable(p->pagetable, p->sz); 143 | p->sz = 0; 144 | 145 | int id = get_id_by_name(name); 146 | bin_loader(id, p); 147 | return 0; 148 | } 149 | ``` 150 | 151 | `get_id_by_name` 仅仅完成了从程序名称到程序 id 的转化,十分简单。 152 | 153 | 看似 exec 非常简单,但其实 `bin_loader` 在第五章有比较大的调整。 154 | 155 | ```c 156 | void bin_loader(uint64 start, uint64 end, struct proc *p) { 157 | uint64 s = PGROUNDDOWN(start), e = PGROUNDUP(end), length = e - s; 158 | // proc_pagetable 完成 trapframe 和 trampoline 的映射 159 | p->pagetable = proc_pagetable(p); 160 | // 完成 .bin 数据的映射 161 | for(uint64 va = BASE_ADDRESS, pa = s; pa < e; va += PGSIZE, pa += PGSIZE) { 162 | void* page = kalloc(); 163 | memmove(page, (const void*)pa, PGSIZE); 164 | mappages(p->pagetable, va, PGSIZE, (uint64)page, PTE_U | PTE_R | PTE_W | PTE_X); 165 | } 166 | // 完成用户栈的映射 167 | alloc_ustack(p); 168 | 169 | p->trapframe->epc = BASE_ADDRESS; 170 | p->sz = USTACK_SIZE + length; 171 | } 172 | ``` 173 | 174 | 其中,对于用户栈、`trapframe`、`trampoline` 的映射没有变化,但是对 .bin 数据的映射似乎面目全非了,竟然由一个循环完成。 175 | 176 | 其实,这个循环的逻辑十分简单,就是对于 .bin 的每一页,都申请一个新页并进行内容拷贝,最后建立这一页的映射。之所以这么麻烦完全是由于我们的物理内存管理过于简陋,一次只能分配一个页,如果能够分配连续的物理页,那么这个循环可以被一个 `mappages` 替代。 177 | 178 | 那么另一个问题是,为什么要拷贝呢?想想 lab4 我们是怎么干的,直接把虚存和物理内存映射就好了,根本没有拷贝。那么,拷贝是为了什么呢?其实,按照 lab4 的做法,程序运行之后就会修改仅有一份的程序"原像",你会发现,lab4 的程序都是一次性的,如果第二次执行,会发现 .data 和 .bss 段数据都被上一次执行改掉了,不是初始化的状态。但是 lab4 的时候,每个程序最多执行一次,所以这么做是可以的。但在 lab5 所有程序都可能被无数次的执行,我们就必须对“程序原像”做保护,在“原像”的拷贝上运行程序了。 179 | 180 | ## wait 181 | 182 | 在 fork 设 定好父子关系之后,wait 的实现就很简单了。你可能好奇为什么进程只有一个 parent 指针,没有 child 数组,这样的话进程要如何知道自己有那些子进程呢?当然维护一个子进程数组也是可以的。至于如何得到子进程,遍历进程数组就好了。 183 | 184 | wait 的接口要求是: 185 | 186 | waitpid 187 | * syscall ID:260 188 | * 功能:当前进程等待一个子进程结束,并获取其返回值。 189 | * C 接口:`int waitpid(int pid, int *status);` 190 | * 参数: 191 | * **pid** 表示要等待结束的子进程的进程 ID,如果为 0或者-1 的话表示等待任意一个子进程结束; 192 | * **status** 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。 193 | * 返回值:如果出现了错误则返回 -1;否则返回结束的子进程的进程 ID。 194 | * 说明: 195 | * 如果子进程存在且尚未完成,该系统调用阻塞等待。 196 | * 可能的错误: 197 | * pid 非法或者指定的不是该进程的子进程。 198 | * 传入的地址 status 不为 0 但是不合法; 199 | 200 | > 顺带一提,C 版代码的实现是阻塞的,但是 Rust 版代码的系统调用是非阻塞的,如果子进程没有结束,不会阻塞等待而是马上返回。所以 rust 在用户态实现了阻塞等待,这不影响实验。 201 | 202 | wait 的思路就是便利进程数组,看有没有和 pid 匹配的进程。如果有且已经结束,按要求返回。如果制定进程不存在或者不是当前进程子进程,返回错误。如果子进程存在但未结束,调用 `sched` 等待。 203 | 204 | 代码如下,实现简单,不做解释: 205 | 206 | ```c 207 | int 208 | wait(int pid, int* code) 209 | { 210 | struct proc *np; 211 | int havekids; 212 | struct proc *p = curr_proc(); 213 | 214 | for(;;){ 215 | // Scan through table looking for exited children. 216 | havekids = 0; 217 | for(np = pool; np < &pool[NPROC]; np++){ 218 | if(np->state != UNUSED && np->parent == p && (pid <= 0 || np->pid == pid)){ 219 | havekids = 1; 220 | if(np->state == ZOMBIE){ 221 | // Found one. 222 | np->state = UNUSED; 223 | pid = np->pid; 224 | *code = np->exit_code; 225 | return pid; 226 | } 227 | } 228 | } 229 | if(!havekids){ 230 | return -1; 231 | } 232 | p->state = RUNNABLE; 233 | sched(); 234 | } 235 | } 236 | ``` 237 | 238 | 最后一提,和前几章不同的是,由于 ch5 的进程有不可复用的资源:代码副本,所以进程结束的时候要进行资源释放,靠如下函数实现: 239 | 240 | ```c 241 | // kernel/proc.c 242 | static void 243 | freeproc(struct proc *p) 244 | { 245 | if(p->trapframe) 246 | kfree((void*)p->trapframe); 247 | p->trapframe = 0; 248 | if(p->pagetable) 249 | proc_freepagetable(p->pagetable, p->sz); // 解除映射并删除对应页面 250 | p->pagetable = 0; 251 | p->state = UNUSED; 252 | } 253 | ``` 254 | 255 | 该函数在进程的 `exit` 函数里调用。 256 | 257 | 重要提醒! 258 | > lab4 的 mmap 打破了连续内存的假定,所以会导致使用 mmap 后,ch5 程序 exit 会出错。对此框架的做法是: 放任内存泄露。(: 反正能 work 等下一轮迭代再修吧...) 259 | 260 | ## shell 261 | 262 | 从此我们就使用 shell 来运行其他程序了,从前面我们知道现在我们的 os 会默认加载 shell 这个用户程序,来看看 shell 的实现吧! 263 | 264 | ```c++ 265 | // 手搓了一个极简的 stack,用来维护用户输入,保存一行的输入 266 | char line[100] = {}; 267 | int top = 0; 268 | void push(char c) { line[top++] = c; } 269 | void pop() { --top; } 270 | int is_empty() { return top == 0; } 271 | void clear() { top = 0; } 272 | 273 | int main() { 274 | printf("C user shell\n"); 275 | printf(">> "); 276 | // shell 是不会结束的 277 | while (1) { 278 | // 读取一个字符 279 | char c = getchar(); 280 | switch (c) { 281 | // 敲了回车,将输入内容解析位一个程序名,通过 fork + exec 执行 282 | case LF: 283 | case CR: 284 | printf("\n"); 285 | if (!is_empty()) { 286 | push('\0'); 287 | int pid = fork(); 288 | if (pid == 0) { 289 | // child process 290 | if (exec(line) < 0) { 291 | printf("no such program\n"); 292 | exit(0); 293 | } 294 | panic("unreachable!"); 295 | } else { 296 | // 父进程 wait 执行的函数 297 | int xstate = 0; 298 | int exit_pid = 0; 299 | exit_pid = wait(pid, &xstate); 300 | assert(pid == exit_pid, -1); 301 | printf("Shell: Process %d exited with code %d\n", pid, xstate); 302 | } 303 | // 无论如何,清空输入 buffer 304 | clear(); 305 | } 306 | printf(">> "); 307 | break; 308 | case BS: 309 | case DL: 310 | // 退格键 311 | if (!is_empty()) { 312 | putchar(BS); 313 | printf(" "); 314 | putchar(BS); 315 | pop(); 316 | } 317 | break; 318 | default: 319 | // 普通输入,回显 320 | putchar(c); 321 | push(c); 322 | break; 323 | } 324 | } 325 | return 0; 326 | } 327 | ``` 328 | 329 | 其实就是一个十分简单粗暴的字符串处理....,但是它确实能用,而且比我们以往运行程序的方式要好用很多! 330 | 331 | ## 展望 332 | 333 | lab5 使得我们可以运行好多好多的进程,使用 fork 可以指数级别的制造新进程,但是这些进程之间如果不能很好的配合,仍然会比较僵硬,所以 ch6 我们将实现进程间通信 pipe. -------------------------------------------------------------------------------- /lab5/shell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeathWish5/ucore-Tutorial-Book/7346c2c4833697f6d2273776b09ef0440502848f/lab5/shell.png -------------------------------------------------------------------------------- /lab6/exercise.md: -------------------------------------------------------------------------------- 1 | # lab6 实验要求 2 | 3 | - 本节难度: **也就和lab3一样吧** 4 | 5 | ## 编程作业 6 | 7 | ### 进程通信:邮件 8 | 9 | 这一章我们实现了基于 pipe 的进程间通信,但是看测例就知道了,管道不太自由,我们来实现一套一看就更靠谱的通信 syscall吧!本节要求实现邮箱机制,以及对应的 syscall。 10 | 11 | * 邮箱说明: 12 | 13 | 每个进程拥有唯一一个邮箱,基于“数据报”收发字节信息,利用环形buffer存储,读写顺序为 FIFO,不记录来源进程。每次读写单位必须为一个报文,如果用于接收的缓冲区长度不够,舍弃超出的部分(截断报文)。为了简单,邮箱中最多拥有16条报文,每条报文最大长度256字节。当邮箱满时,发送邮件(也就是写邮箱会失败)。不考虑读写邮箱的权限,也就是所有进程都能够随意给其他进程的邮箱发报。 14 | 15 | mailread 16 | * syscall ID:401 17 | * C接口:`int mailread(void* buf, int len)` 18 | * Rust接口: `fn mailread(buf: *mut u8, len: usize)` 19 | * 功能:读取一个报文,如果成功返回报文长度. 20 | * 参数: 21 | * buf: 缓冲区头。 22 | * len:缓冲区长度。 23 | * 说明: 24 | * len > 256 按 256 处理,len < 队首报文长度且不为0,则截断报文。 25 | * len = 0,则不进行读取,如果没有报文读取,返回-1,否则返回0,这是用来测试是否有报文可读。 26 | * 可能的错误: 27 | * 邮箱空。 28 | * buf 无效。 29 | 30 | mailwrite 31 | * syscall ID:402 32 | * C接口:`int mailwrite(int pid, void* buf, int len)` 33 | * Rust接口: `fn mailwrite(pid: usize, buf: *mut u8, len: usize)` 34 | * 功能:向对应进程邮箱插入一条报文. 35 | * 参数: 36 | * pid: 目标进程id。 37 | * buf: 缓冲区头。 38 | * len:缓冲区长度。 39 | * 说明: 40 | * len > 256 按 256 处理, 41 | * len = 0,则不进行写入,如果邮箱满,返回-1,否则返回0,这是用来测试是否可以发报。 42 | * 可以向自己的邮箱写入报文。 43 | * 可能的错误: 44 | * 邮箱满。 45 | * buf 无效。 46 | 47 | ### 实验要求 48 | 49 | - 实现分支:ch6。 50 | - 完成实验指导书中的内容,实现进程控制,可以基于 pipe 进行进程通信。 51 | - 实现邮箱机制及系统调用,并通过 [C测例](https://github.com/DeathWish5/riscvos-c-tests)中 chapter6 对应的所有测例。 52 | 53 | challenge: 支持多核。 54 | 55 | ### 实验检查 56 | 57 | - 实验目录要求 58 | 59 | 目录要求不变(参考lab1目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。 60 | 61 | 加载的用户测例位置: `../user/target/bin`。 62 | 63 | - 检查 64 | 65 | 可以正确 `make run` 执行,可以正确执行目标用户测例,并得到预期输出(详见测例注释)。 66 | 67 | ## 问答作业 68 | 69 | 1. 举出使用 pipe 的一个实际应用的例子。 70 | 71 | 2. 假设我们的邮箱现在有了更加强大的功能,容量大幅增加而且记录邮件来源,可以实现“回信”。考虑一个多核场景,有 m 个核为消费者,n 个为生产者,消费者通过邮箱向生产者提出订单,生产者通过邮箱回信给出产品。 72 | 73 | - 假设你的邮箱实现没有使用锁等机制进行保护,在多核情景下可能会发生那些问题?单核一定不会发生问题吗?为什么? 74 | - 请结合你在课堂上学到的内容,描述读者写者问题的经典解决方案,必要时提供伪代码。 75 | - 由于读写是基于报文的,不是随机读写,你有什么点子来优化邮箱的实现吗? 76 | 77 | 78 | ## 报告要求 79 | 80 | * 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) 81 | * 完成问答问题 82 | * (optional) 你对本次实验设计及难度的看法。 -------------------------------------------------------------------------------- /lab6/guide.md: -------------------------------------------------------------------------------- 1 | # 进程间通信 2 | 3 | 废话不说了,这一章写进程间通信,具体实现是 pipe。我们来看看与 pipe 相关的系统调用长什么样子。 4 | 5 | ```c++ 6 | /// 功能:为当前进程打开一个管道。 7 | /// 参数:pipe 表示应用地址空间中的一个长度为 2 的 long 数组的起始地址,内核需要按顺序将管道读端和写端的文件描述符写入到数组中。 8 | /// 返回值:如果出现了错误则返回 -1,否则返回 0 。可能的错误原因是:传入的地址不合法。 9 | /// syscall ID:59 10 | long sys_pipe(long fd[2]); 11 | 12 | // ... 13 | long sys_read(int fd, void* buf, size_t size); 14 | 15 | // ... 16 | long sys_write(int fd, void* buf, size_t size); 17 | 18 | /// 功能:当前进程关闭一个文件。 19 | /// 参数:fd 表示要关闭的文件的文件描述符。 20 | /// 返回值:如果成功关闭则返回 0 ,否则返回 -1 。可能的出错原因:传入的文件描述符并不对应一个打开的文件。 21 | /// syscall ID:57 22 | long sys_close(int fd); 23 | ``` 24 | 25 | 此外还有读写管道的系统调用,其实就是 sys_write 和 sys_read,这两个是我们的老朋友了,此外还需要实现 sys_close 用来关闭管道。 26 | 27 | 秉承越简单越好、能跑就行就方针,ucore-tutorial 对于 pipe 的设计也十分简单: 找一块空闲内存作为 pipe 的 data buffer,对 pipe 的读写就转化为了对这块内存的读写。想一想是不是特别简单?但是再仔细想想,sys_write 还需要完成屏幕输出,一个程序还可以拥有多个 pipe,而且 pipe 还要能够使得其他程序可见来完成进程通讯的功能,对每个 pipe 还要维护一些状态来记录上一次读写到的位置和 pipe 实际可读的 size等。其实,这里所有的麻烦点大多来自于文件系统接口对我们的要求,考虑到 lab7 就要实现 file system 了,在 lab6 我们将实现文件系统的雏形。 28 | 29 | ## 文件系统初步 30 | 31 | 首先考虑刚才说到的全局可见,没错,一个需要文件能够被多个进程打开,文件不是一个进程的属性,而是整个 os 的属性。为此,我们需要一个全局文件表(虽然这个时候里面只有 pipe...) 32 | 33 | 见 `file.h`: 34 | 35 | ```c++ 36 | // pipe.h 37 | #define PIPESIZE 512 38 | 39 | struct pipe { 40 | char data[PIPESIZE]; 41 | uint nread; // number of bytes read 42 | uint nwrite; // number of bytes written 43 | int readopen; // read fd is still open 44 | int writeopen; // write fd is still open 45 | }; 46 | 47 | // file.h 48 | struct file { 49 | enum { FD_NONE = 0, FD_PIPE} type; // FD_NODE means this file is null. 50 | int ref; // reference count 51 | char readable; 52 | char writable; 53 | struct pipe *pipe; // FD_PIPE 54 | }; 55 | 56 | // 全局文件池 57 | extern struct file filepool[128 * 16]; 58 | ``` 59 | 60 | 这里我们可以看到全局文件池和 `struct file` 的定义,注意文件是使用引用计数来管理的。目前,我们不认为 `stdin`、`stdout`、`stderr` 是真正的文件,而是直接和串口接在一起,这是一种临时的实现,在后续实验中会改掉,所以文件类型中只有 `FD_PIPE` 有效。 61 | 62 | 如下是我们对于文件的分配和关闭(注意我们还没有实现 `sys_open`),分配很简单,遍历进程池找 `ref == 0` 的,关闭就是 `ref--`,如果为 0 就真的关闭,对于 pipe 来说就是执行 `pipe_close`。 63 | 64 | ```c++ 65 | // kernel/file.c 66 | struct file* filealloc() { 67 | for(int i = 0; i < FILE_MAX; ++i) { 68 | if(filepool[i].ref == 0) { 69 | filepool[i].ref = 1; 70 | return &filepool[i]; 71 | } 72 | } 73 | return 0; 74 | } 75 | 76 | void 77 | fileclose(struct file *f) 78 | { 79 | if(--f->ref > 0) { 80 | return; 81 | } 82 | if(f->type == FD_PIPE){ 83 | pipeclose(f->pipe, f->writable); 84 | } 85 | memset(f, 0, sizeof(struct file)); 86 | } 87 | ``` 88 | 89 | 然后,进程也需要增加文件相关支持。进程控制块增加文件指针数组。 90 | 91 | ```diff 92 | // proc.h 93 | // Per-process state 94 | struct proc { 95 | // ... 96 | 97 | + struct file* files[16]; 98 | }; 99 | ``` 100 | 101 | 每一个文件指针与对应 fd 关联,fd分配很简单,遍历寻找空闲 fd。 102 | 103 | ```c++ 104 | // kernel/proc.c 105 | int fdalloc(struct file* f) { 106 | struct proc* p = curr_proc(); 107 | // fd = 0,1,2 is reserved for stdio/stdout/stderr 108 | for(int i = 3; i < FD_MAX; ++i) { 109 | if(p->files[i] == 0) { 110 | p->files[i] = f; 111 | return i; 112 | } 113 | } 114 | return -1; 115 | } 116 | ``` 117 | 118 | ## pipe 函数 119 | 120 | 现在我们来看看 `pipe_close` 等是咋做的(`kernel/pipe.c`)。首先是 pipe 的分配: 121 | 122 | ```c++ 123 | int 124 | pipealloc(struct file *f0, struct file *f1) 125 | { 126 | // 这里没有用预分配,由于 pipe 比较大,直接拿一个页过来,也不算太浪费 127 | struct pipe *pi = (struct pipe*)kalloc(); 128 | // 一开始 pipe 可读可写,但是已读和已写内容为 0 129 | pi->readopen = 1; 130 | pi->writeopen = 1; 131 | pi->nwrite = 0; 132 | pi->nread = 0; 133 | 134 | // 两个参数分别通过 filealloc 得到,把该 pipe 和这两个文件关连,一端可读,一端可写。读写端控制是 sys_pipe 的要求。 135 | f0->type = FD_PIPE; 136 | f0->readable = 1; 137 | f0->writable = 0; 138 | f0->pipe = pi; 139 | 140 | f1->type = FD_PIPE; 141 | f1->readable = 0; 142 | f1->writable = 1; 143 | f1->pipe = pi; 144 | return 0; 145 | } 146 | ``` 147 | 148 | pipe 的关闭: 149 | 150 | ```c++ 151 | // 该函数其实只关闭了读写端中的一个,如果两个都被关闭,释放 pipe。 152 | void 153 | pipeclose(struct pipe *pi, int writable) 154 | { 155 | if(writable){ 156 | pi->writeopen = 0; 157 | } else { 158 | pi->readopen = 0; 159 | } 160 | if(pi->readopen == 0 && pi->writeopen == 0){ 161 | kfree((char*)pi); 162 | } 163 | } 164 | ``` 165 | 166 | pipe 的读写:(注意,pipe 是使用 ring buffer 管理的) 167 | 168 | ```c++ 169 | int 170 | pipewrite(struct pipe *pi, uint64 addr, int n) 171 | { 172 | // w 记录已经写的字节数 173 | int w = 0; 174 | struct proc *p = curr_proc(); 175 | while(w < n){ 176 | // 若不可读,写也没有意义 177 | if(pi->readopen == 0){ 178 | return -1; 179 | } 180 | 181 | if(pi->nwrite == pi->nread + PIPESIZE){ 182 | // pipe write 端已满,阻塞 183 | yield(); 184 | } else { 185 | // 一次读的 size 为 min(用户buffer剩余,pipe 剩余写容量,pipe 剩余线性容量) 186 | uint64 size = MIN( 187 | n - w, 188 | pi->nread + PIPESIZE - pi->nwrite, 189 | PIPESIZE - (pi->nwrite % PIPESIZE) 190 | ); 191 | // 使用 copyin 读入用户 buffer 内容 192 | copyin(p->pagetable, &pi->data[pi->nwrite % PIPESIZE], addr + w, size); 193 | pi->nwrite += size; 194 | w += size; 195 | } 196 | } 197 | return w; 198 | } 199 | 200 | int 201 | piperead(struct pipe *pi, uint64 addr, int n) 202 | { 203 | // r 记录已经写的字节数 204 | int r = 0; 205 | struct proc *p = curr_proc(); 206 | // 若 pipe 可读内容为空,阻塞或者报错 207 | while(pi->nread == pi->nwrite) { 208 | if(pi->writeopen) 209 | yield(); 210 | else 211 | return -1; 212 | } 213 | while(r < n && size != 0) { 214 | // pipe 可读内容为空,返回 215 | if(pi->nread == pi->nwrite) 216 | break; 217 | // 一次写的 size 为:min(用户buffer剩余,可读内容,pipe剩余线性容量) 218 | uint64 size = MIN( 219 | n - r, 220 | pi->nwrite - pi->nread, 221 | PIPESIZE - (pi->nread % PIPESIZE) 222 | ); 223 | // 使用 copyout 写用户内存 224 | copyout(p->pagetable, addr + r, &pi->data[pi->nread % PIPESIZE], size); 225 | pi->nread += size; 226 | r += size; 227 | } 228 | return r; 229 | } 230 | ``` 231 | 232 | 好了,到现在,我们可以来实现那几个系统调用了。 233 | 234 | ## pipe 相关系统调用 235 | 236 | 首先是 `sys_pipe`,记不记得 一开始描述的 `sys_pipe` 接口? 237 | 238 | ```c++ 239 | // kernel/syscall.c 240 | uint64 241 | sys_pipe(uint64 fdarray) { 242 | struct proc *p = curr_proc(); 243 | // 申请两个空 file 244 | struct file* f0 = filealloc(); 245 | struct file* f1 = filealloc(); 246 | // 实际分配一个 pipe,与两个文件关联 247 | pipealloc(f0, f1); 248 | // 分配两个 fd,并将之与 文件指针关联 249 | fd0 = fdalloc(f0); 250 | fd1 = fdalloc(f1); 251 | size_t PSIZE = sizeof(fd0); 252 | copyout(p->pagetable, fdarray, &fd0, sizeof(fd0)); 253 | copyout(p->pagetable, fdarray + sizeof(uint64), &fd1, sizeof(fd1)); 254 | return 0; 255 | } 256 | ``` 257 | 258 | sys_close 比较简单: 259 | 260 | ```c++ 261 | uint64 sys_close(int fd) { 262 | // stdio/stdout/stderr can't be closed for now 263 | if(fd <= 2) 264 | return 0; 265 | struct proc *p = curr_proc(); 266 | fileclose(p->files[fd]); 267 | p->files[fd] = 0; 268 | return 0; 269 | } 270 | ``` 271 | 272 | 原来的 `sys_write` 更名为 `console_write`,新 `sys_write` 根据文件类型分别调用 `console_write` 和 `pipe_write`。`sys_read` 同理。 273 | 274 | ```c++ 275 | uint64 sys_write(int fd, uint64 va, uint64 len) { 276 | if(fd <= 2) { 277 | return console_write(va, len); 278 | } 279 | struct proc *p = curr_proc(); 280 | struct file *f = p->files[fd]; 281 | if(f->type == FD_PIPE) { 282 | return pipewrite(f->pipe, va, len); 283 | } 284 | error("unknown file type %d\n", f->type); 285 | return -1; 286 | } 287 | 288 | uint64 sys_read(int fd, uint64 va, uint64 len) { 289 | if(fd <= 2) { 290 | return console_read(va, len); 291 | } 292 | struct proc *p = curr_proc(); 293 | struct file *f = p->files[fd]; 294 | if(f->type == FD_PIPE) { 295 | return piperead(f->pipe, va, len); 296 | } 297 | error("unknown file type %d\n", f->type); 298 | return -1; 299 | } 300 | ``` 301 | 302 | 以上。 303 | 304 | ## 进程通讯与 fork 305 | 306 | fork 为什么是毒瘤呢?因为你总是要在新增加一个东西以后考虑要不要为新功能增加 fork 支持。这一章的文件就是第一个例子,那么在 fork 语境下,文件应该如何处理呢?我们来看看 fork 在这一个 chapter 的实现: 307 | 308 | ```diff 309 | int fork() { 310 | // ... 311 | + for(int i = 3; i < FD_MAX; ++i) 312 | + if(p->files[i] != 0 && p->files[i]->type != FD_NONE) { 313 | + p->files[i]->ref++; 314 | + np->files[i] = p->files[i]; 315 | + } 316 | // ... 317 | } 318 | ``` 319 | 320 | 没错,只需要拷贝文件指针并增加文件引用计数就好了,不需要重新新建一个文件。那么,`exec` 呢?你会发现 `exec` 的实现竟然没有修改,注意 `exec` 仅仅重新加载进程执行的文件镜像,不会改变其他属性,比如文件。也就是说,`fork` 出的子进程打开了与父进程相同的文件,但是 exec 并不会把打开的文件刷掉,基于这一点,我们可以利用 pipe 进行进程间通信。在用户态有一个简单例子: 321 | 322 | 见 `user/pipetest.c`: 323 | 324 | ```c++ 325 | char STR[] = "hello pipe!"; 326 | 327 | int main() { 328 | uint64 pipe_fd[2]; 329 | int ret = pipe(&pipe_fd); 330 | if (fork() == 0) { 331 | // 子进程,从 pipe 读,和 STR 比较。 332 | char buffer[32 + 1]; 333 | read(pipe_fd[0], buffer, 32); 334 | assert(strncmp(buffer, STR, strlen(STR) == 0); 335 | exit(0); 336 | } else { 337 | // 父进程,写 pipe 338 | write(pipe_fd[1], STR, strlen(STR)); 339 | int exit_code = 0; 340 | wait(&exit_code); 341 | assert(exit_code == 0); 342 | } 343 | return 0; 344 | } 345 | ``` 346 | 347 | ## 展望 348 | 349 | 这一章已经给 lab7 打好一定的底子了,lab7 我们将拥有文件系统!我们的 os 将拥有操纵持久化存储的能力,wow! 350 | 351 | 352 | -------------------------------------------------------------------------------- /lab7/exercise.md: -------------------------------------------------------------------------------- 1 | # lab7 实验要求 2 | 3 | - 本节难度: **难度较大** 4 | 5 | ## 编程作业 6 | 7 | ### 硬链接 8 | 9 | 你的电脑桌面是咋样的?是放满了图标吗?反正我的 windows 是这样的。显然很少人会真的把可执行文件放到桌面上,桌面图标其实都是一些快捷方式。或者用 unix 的术语来说:软链接。为了减少工作量,我们今天来实现软链接的兄弟:[硬链接](https://en.wikipedia.org/wiki/Hard_link)。 10 | 11 | 硬链接要求两个不同的目录项指向同一个文件,在我们的文件系统中也就是两个不同名称目录项指向同一个磁盘块。本节要求实现三个系统调用 sys_linkat、sys_unlinkat、sys_stat。 12 | 13 | link: 14 | * syscall ID: 37 15 | * 功能:创建一个文件的一个硬链接,[具体含义](https://linux.die.net/man/2/linkat)。 16 | * C接口: `int linkat(int olddirfd, char* oldpath, int newdirfd, char* newpath, unsigned int flags)` 17 | * Rust 接口: `fn linkat(olddirfd: i32, oldpath: *const u8, newdirfd: i32, newpath: *const u8, flags: u32) -> i32` 18 | * 参数: 19 | * olddirfd,newdirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。 20 | * flags: 仅为了兼容性考虑,本次实验中始终为 0,可以忽略。 21 | * oldpath:原有文件路径 22 | * newpath: 新的链接文件路径。 23 | * 说明: 24 | * 为了方便,不考虑新文件路径已经存在的情况(属于未定义行为),除非链接同名文件。 25 | * 返回值:如果出现了错误则返回 -1,否则返回 0。 26 | * 可能的错误 27 | * 链接同名文件。 28 | 29 | unlink: 30 | * syscall ID: 35 31 | * 功能:取消一个文件路径到文件的链接,[具体含义](https://linux.die.net/man/2/unlinkat)。 32 | * C接口: `int unlinkat(int dirfd, char* path, unsigned int flags)` 33 | * Rust 接口: `fn unlinkat(dirfd: i32, path: *const u8, flags: u32) -> i32` 34 | * 参数: 35 | * dirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。 36 | * flags: 仅为了兼容性考虑,本次实验中始终为 0,可以忽略。 37 | * path:文件路径。 38 | * 说明: 39 | * 为了方便,不考虑使用 unlink 彻底删除文件的情况。 40 | * 返回值:如果出现了错误则返回 -1,否则返回 0。 41 | * 可能的错误 42 | * 文件不存在。 43 | 44 | fstat: 45 | * syscall ID: 80 46 | * 功能:获取文件状态。 47 | * C接口: `int fstat(int fd, struct Stat* st)` 48 | * Rust 接口: `fn fstat(fd: i32, st: *mut Stat) -> i32` 49 | * 参数: 50 | * fd: 文件描述符 51 | * st: 文件状态结构体 52 | ```c 53 | struct Stat { 54 | uint64 dev, // 文件所在磁盘驱动器号 55 | uint64 ino, // inode 文件所在 inode 编号 56 | uint32 mode, // 文件类型 57 | uint32 nlink, // 硬链接数量,初始为1 58 | uint64 pad[7], // 无需考虑,为了兼容性设计 59 | } 60 | 61 | // 文件类型只需要考虑: 62 | #define DIR 0x040000 // directory 63 | #define FILE 0x100000 // ordinary regular file 64 | ``` 65 | * 返回值:如果出现了错误则返回 -1,否则返回 0。 66 | * 可能的错误 67 | * fd 无效。 68 | * st 地址非法。 69 | 70 | ### 实验要求 71 | 72 | - 实现分支:ch7。 73 | - 完成实验指导书中的内容,实现基本的文件操作。 74 | - 实现硬链接及相关系统调用,并通过 [C测例](https://github.com/DeathWish5/riscvos-c-tests)中 chapter7 对应的所有测例。 75 | 76 | challenge: 支持多核。 77 | 78 | ### 实验检查 79 | 80 | - 实验目录要求 81 | 82 | 目录要求不变(参考lab1目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。 83 | 84 | 加载的用户测例位置: `../user/target/bin`。 85 | 86 | - 检查 87 | 88 | 可以正确 `make run` 执行,可以正确执行目标用户测例,并得到预期输出(详见测例注释)。 89 | 90 | ## 问答作业 91 | 92 | 1. 目前的文件系统只有单级目录,假设想要支持多级文件目录,请描述你设想的实现方式,描述合理即可。 93 | 94 | 2. 在有了多级目录之后,我们就也可以为一个目录增加硬链接了。在这种情况下,文件树中是否可能出现环路?你认为应该如何解决?请在你喜欢的系统上实现一个环路(软硬链接都可以,鼓励多尝试),描述你的实现方式以及系统提示、实际测试结果。 95 | 96 | ## 报告要求 97 | 98 | * 简单总结本次实验与上个实验相比你增加的东西。(控制在5行以内,不要贴代码) 99 | * 完成问答问题 100 | * (optional) 你对本次实验设计及难度的看法。 101 | -------------------------------------------------------------------------------- /lab7/guide.md: -------------------------------------------------------------------------------- 1 | # 文件系统 2 | 3 | 这一章实现文件系统,也就是管理磁盘模块。首先,一切的基础,需要有读写磁盘的能力。 4 | 5 | ## 内存缓存与 virtio 磁盘驱动 6 | 7 | 注意:这一部分代码不需要同学们详细了解细节。 8 | 9 | 首先,我们使用 qemu 的虚拟磁盘 virtio 作为我们的磁盘。注意 Makefile 中的 qemu 参数增加了两行: 10 | 11 | ```diff 12 | QEMU = qemu-system-riscv64 13 | QEMUOPTS = \ 14 | -nographic \ 15 | -smp $(CPUS) \ 16 | -machine virt \ 17 | -bios $(BOOTLOADER) \ 18 | -kernel kernel \ 19 | + -drive file=$(U)/fs-copy.img,if=none,format=raw,id=x0 \ # 以 user/fs-copy.img 作为磁盘镜像 20 | + -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 # 虚拟 virtio 磁盘设备 21 | ``` 22 | 23 | 在 ucore-tutorial 中磁盘块的读写是通过中断处理的。在 `virtio.h` 和 `virtio-disk.c` 中我们按照 qemu 对 virtio 的定义,实现了 `virtio_disk_init` 和 `virtio_disk_rw` 两个函数,前者完成磁盘设备的初始化和对其管理的初始化。`virtio_disk_rw` 实际完成磁盘IO,当设定好读写信息后会通过 MMIO 的方式通知磁盘开始写。然后,os 会开启中断并开始死等磁盘读写完成。当磁盘完成 IO 后,磁盘会触发一个外部中断,在中断处理中会把死循环条件解除。 24 | 25 | ```c++ 26 | virtio_disk_rw(struct buf *b, int write) { 27 | /// ... set IO config 28 | *R(VIRTIO_MMIO_QUEUE_NOTIFY) = 0; // notify the disk to carry out IO 29 | struct buf volatile * _b = b; // Make sure complier will load 'b' form memory 30 | intr_on(); 31 | while(_b->disk == 1); // _b->disk == 0 means that this IO is done 32 | intr_off(); 33 | } 34 | ``` 35 | 36 | 中断处理流程调整有:<1>增加 kernel trap 的处理;<2>增加外部中断的响应;<3> kernel 态处理时钟中断是,不切换进程。 37 | 38 | ##### trap form kernel 39 | 40 | riscv 是支持内核发生中断在内核处理的,也就是所谓的中断嵌套,为了支持这一点,首先需要增加区别于用户中断入口的内核中断处理入口 `kernelvec`: 41 | 42 | ```c 43 | // trap.c 44 | extern char kernelvec[]; 45 | void set_kerneltrap(void) { 46 | // 进入内核后设置 stvec 为 kernelvec 47 | w_stvec((uint64) kernelvec & ~0x3); 48 | } 49 | ``` 50 | 51 | `kernelvec.S` 的就具体内容十分简单,由于没有发生特权级的切换,所以这段汇编很类似于函数调用的寄存器v保存恢复,只不过处理了所有通用寄存器。甚至比 switch 还简单。`x0` 为常量不需要保存,tp 寄存器单核情况下不起作用,可以不关心。值得注意的是,在 restore 的时候没有特殊处理 `sp` 寄存器,同学们可以思考一下这是为什么,直接 `ld sp, 8(sp)` 不会有问题吗?(放心,由于 rust 处理方式不同,所以其实期末不会考)。 52 | 53 | ```asm 54 | # kernelvec.S 55 | 56 | kernelvec: 57 | // make room to save registers. 58 | addi sp, sp, -256 59 | // save the registers expect x0 60 | sd ra, 0(sp) 61 | sd sp, 8(sp) 62 | sd gp, 16(sp) 63 | // ... 64 | sd t4, 224(sp) 65 | sd t5, 232(sp) 66 | sd t6, 240(sp) 67 | 68 | call kerneltrap 69 | 70 | kernelret: 71 | // restore registers. 72 | ld ra, 0(sp) 73 | ld sp, 8(sp) 74 | ld gp, 16(sp) 75 | // restore all registers expect x0 76 | ld t4, 224(sp) 77 | ld t5, 232(sp) 78 | ld t6, 240(sp) 79 | 80 | addi sp, sp, 256 81 | 82 | sret 83 | ``` 84 | 85 | `kernelvec.S` 中调用了 `kerneltrap` 函数,如下: 86 | 87 | ```c++ 88 | void kerneltrap() { 89 | // 老三样,不过在这里把处理放到了 C 代码中 90 | uint64 sepc = r_sepc(); 91 | uint64 sstatus = r_sstatus(); 92 | uint64 scause = r_scause(); 93 | 94 | if ((sstatus & SSTATUS_SPP) == 0) 95 | panic("kerneltrap: not from supervisor mode"); 96 | 97 | if (scause & (1ULL << 63)) { 98 | // 可能发生时钟中断和外部中断,我们的主要目标是处理外部中断 99 | devintr(scause & 0xff); 100 | } else { 101 | // kernel 发生异常就挣扎了,肯定出问题了,杀掉用户线程跑路 102 | error("invalid trap from kernel: %p, stval = %p sepc = %p\n", scause, r_stval(), sepc); 103 | exit(-1); 104 | } 105 | } 106 | ``` 107 | 108 | 外部中断处理函数 `devintr` 如下,主要处理时钟和外部中断: 109 | 110 | ```c++ 111 | void devintr(uint64 cause) { 112 | int irq; 113 | switch (cause) { 114 | case SupervisorTimer: 115 | set_next_timer(); 116 | // 时钟中断如果发生在内核态,不切换进程,原因分析在下面 117 | // 如果发生在用户态,照常处理 118 | if((r_sstatus() & SSTATUS_SPP) == 0) { 119 | yield(); 120 | } 121 | break; 122 | case SupervisorExternal: 123 | irq = plic_claim(); 124 | if (irq == UART0_IRQ) { // UART 串口的终端不需要处理,这个 rustsbi 替我们处理好了 125 | // do nothing 126 | } else if (irq == VIRTIO0_IRQ) { // 我们等的就是这个中断 127 | virtio_disk_intr(); 128 | } 129 | if (irq) 130 | plic_complete(irq); // 表明中断已经处理完毕 131 | break; 132 | } 133 | } 134 | ``` 135 | 136 | `virtio_disk_intr()` 会把 buf->disk 置零,这样中断返回后死循环条件接触,程序可以继续运行。具体代码在 `virtio-disk.c` 中。 137 | 138 | 这里还需要注意的一点是,为什么始终不允许内核发生进程切换呢?只是由于我们的内核并没有并发的支持,相关的数据结构没有锁或者其他机制保护。考虑这样一种情况,一个进程读写一个文件,内核处理等待磁盘相应时,发生时钟中断切换到了其他进程,然而另一个进程也要读写同一个文件,这就可能发生数据访问上的冲突,甚至导致磁盘出现错误的行为。这也是为什么内核态一直不处理时钟中断,我们必须保证每一次内核的操作都是原子的,不能被打断。 139 | 140 | 事实上可以看到,我们在内核中是全程关中断的(注意 lab2 不应该开启中断,这个操作是导致 ch7-bug-fix 这个分支的元凶),只有在不得已的时候,也就是等待磁盘中断的时候短暂的开启中断,完成之后马上关闭。大家可以想一想,如果内核可以随时切换,当前有那些数据结构可能被破坏。提示:想想 kalloc 分配到一半,进程 switch 切换到一半之类的。 141 | 142 | ##### 磁盘缓存 143 | 144 | 为了加快磁盘访问的速度,在内核中设置了磁盘缓存 `struct buf`,一个 buf 对应一个磁盘 block,这一部分代码也不要求同学们深入掌握。大致的作用机制是,对磁盘的读写都会被转化为对 `buf` 的读写,当 buf 有效时,读写 buf,buf 无效时(类似页表缺页和 TLB 缺失),就实际读写磁盘,将 buf 变得有效,然后继续读写 buf。详细的内容在 `buf.h` 和 `bio.c` 中。 145 | 146 | buf 写回的时机是 buf 池满需要替换的时候(类似内存的 swap 策略) 手动写回。如果 buf 没有写回,一但掉电就 GG 了,所以手动写回还是挺重要的。 147 | 148 | 重要函数功能描述如下,基本了解作用机制即可。 149 | 150 | ```c++ 151 | struct buf { 152 | uint dev; // buf 缓存的磁盘 block 对应的块号和设备号 153 | uint blockno; 154 | int valid; // 表明 data 是否有效 155 | int disk; // 用作磁盘读写时等待 156 | uint refcnt; // 引用计数,为 0 时执行写回操作 157 | struct buf *prev; // buf 池链表 158 | struct buf *next; 159 | uchar data[BSIZE]; // 数据块 160 | }; 161 | 162 | // 全局初始化函数,建立 buf 池的链表链接 163 | void binit(void); 164 | 165 | // Helper function. 166 | // Look through the buf for block on device dev. 167 | // If not found, allocate a buffer. 168 | static struct buf * bget(uint dev, uint blockno); 169 | 170 | // Return a buf with the contents of the indicated block. 171 | // use `bget` and `virtio_disk_rw` 172 | struct buf * bread(uint dev, uint blockno); 173 | 174 | // Write back b's contents to disk. 175 | // use `virtio_disk_rw` 176 | void bwrite(struct buf *b); 177 | 178 | // Release a buffer. 179 | void brelse(struct buf *b); 180 | 181 | void bpin(struct buf *b) { 182 | b->refcnt++; 183 | } 184 | 185 | void bunpin(struct buf *b) { 186 | b->refcnt--; 187 | } 188 | ``` 189 | 190 | 可以通过 `bread` 来读取特定磁盘 block 的内容,可以通过 `bwrite` 写回。 191 | 192 | 需要特别注意 brelse 函数: 193 | 194 | ```c++ 195 | void brelse(struct buf *b) { 196 | b->refcnt--; 197 | if (b->refcnt == 0) { 198 | b->next->prev = b->prev; 199 | b->prev->next = b->next; 200 | b->next = bcache.head.next; 201 | b->prev = &bcache.head; 202 | bcache.head.next->prev = b; 203 | bcache.head.next = b; 204 | } 205 | } 206 | ``` 207 | 208 | **需要特别注意**的是 `brelse` 不会真的如字面意思释放一个 buf,。它的准确含义是暂时不操作该 buf 了,buf 的真正释放会被推迟到 buf 池满,无法分配的时候,就会把最近最久未使用的 buf 释放掉(释放 = 写回 + 清空)。这是为了仅可能保留内存缓存,因为读写磁盘真的太太太太慢了。 209 | 210 | 此外,brelse 的数量必须和 bget 相同,因为 bget 会是的引用计数加一。如果没有相匹配的 brelse,就好比 new 了之后没有 delete。千万注意。 211 | 212 | ## nfs 文件系统 213 | 214 | 在 `fs.h` 和 `fs.c` 中,我们实现了 naive fs 的主要逻辑,注意这部分逻辑要和另一个目录中的 `nfs/fs.c` 相匹配。 215 | 216 | ##### 文件系统磁盘布局与文件读取流程 217 | 218 | 在 `nfs/fs.c` 中揭示了磁盘数据的布局: 219 | 220 | ```c++ 221 | // 基本信息:块大小 BSIZE = 1024B,总容量 FSSIZE = 1000 个 block = 1000 * 1024 B。 222 | // Layout: 223 | // 0号块目前不起作用,可以忽略。superblock 固定为 1 号块,size 固定为一个块。 224 | // 其后是储存 inode 的若干个块,占用块数 = inode 上限 / 每个块上可以容纳的 inode 数量, 225 | // 其中 inode 上限固定为 200,每个块的容量 = BSIZE / sizeof(struct disk_inode) 226 | // 再之后是数据块相关内容,包含一个 储存空闲块位置的 bitmap 和 实际的数据块,bitmap 块 227 | // 数量固定为 NBITMAP = FSSIZE / (BSIZE * 8) + 1 = 1000 / 8 + 1 = 126 块。 228 | // [ boot block | sb block | inode blocks | free bit map | data blocks ] 229 | ``` 230 | 231 | 注意:不推荐同学们修改该布局,除非你完全看懂了 fs 的逻辑,所以最好不要改变 `disk_inode` 这个结构的大小,如果想要增删字段,一定使用 pad。 232 | 233 | 那么什么是 inode 和 data block 呢?以下是 superblock, dinode,inode, dirent 三个结构体定义(务必基本理解): 234 | 235 | ```c++ 236 | // 超级块位置固定,用来指示文件系统的一些元数据,这里最重要的是 inodestart 和 bmapstart 237 | struct superblock { 238 | uint magic; // Must be FSMAGIC 239 | uint size; // Size of file system image (blocks) 240 | uint nblocks; // Number of data blocks 241 | uint ninodes; // Number of inodes. 242 | uint inodestart;// Block number of first inode block 243 | uint bmapstart; // Block number of first free map block 244 | }; 245 | 246 | // On-disk inode structure 247 | // 储存磁盘 inode 信息,主要是文件类型和数据块的索引,其大小影响磁盘布局,不要乱改,可以用 pad 248 | struct dinode { 249 | short type; // File type 250 | short pad[3]; 251 | uint size; // Size of file (bytes) 252 | uint addrs[NDIRECT + 1];// Data block addresses 253 | }; 254 | 255 | // in-memory copy of an inode 256 | // dinode 的内存缓存,为了方便,增加了 dev, inum, ref, valid 四项管理信息,大小无所谓,可以随便改。 257 | struct inode { 258 | uint dev; // Device number 259 | uint inum; // Inode number 260 | int ref; // Reference count 261 | int valid; // inode has been read from disk? 262 | short type; // copy of disk inode 263 | uint size; 264 | uint addrs[NDIRECT+1]; // data block num 265 | }; 266 | 267 | // 目录对应的数据块的内容本质是 filename 到 file inode_num 的一个 map,这里为了简单,就存为一个 `dirent` 数组,查找的时候遍历对比 268 | struct dirent { 269 | ushort inum; 270 | char name[DIRSIZ]; 271 | }; 272 | ``` 273 | 274 | 注意几个量的概念: 275 | 276 | * block num: 表示某一个磁盘块的编号。 277 | * inode num: 表示某一个 inode 在所有 inode 项里的编号。注意 inode blocks 其实就是一个 inode 的大数组。 278 | 279 | 同时,目录本身是一个 filename 到 file inode num 的 map,可以完成 filename 到 inode_num 的转化。 280 | 281 | 注意:为了简单,我们的文件系统只有单级目录,也就是只有根目录一个目录,所有文件都在这一个目录里,这个目录里也再没有目录。 282 | 283 | 那么,在没有任何内存缓存的情形下,如何从磁盘中读取一个文件呢?更加具体来说,`filewrite(filename, "hello world")` 这句伪代码是如何执行的? 284 | 285 | 首先,我们需要找到该文件对应的 inode,这个过程是:首先找到根目录的 inode,其 inode_num 固定为 0,可以在第一个 inode block 的第一项找到,root_inode 会指示数据块的位置,这些 block 是一个 filename 到 file inode_num 的一个 map,从而依据 filename 得到 inode_num。再次回到 inode blocks,找到第 inode_num 个,就得到了文件对应的 inode。在该 inode 中,可以找到文件的类型、大小和对应数据块的编号(block num),最后就可以依据 block num 找到对应的数据块,最终完成读写。 286 | 287 | inode blocks 的位置在 superblock 中有指示。 288 | 289 | 理解了 superblock / inode / data-block 的套路之后,我们来看看如何如何完成文件操作。 290 | 291 | ##### 文件系统实现 292 | 293 | 还是从 syscall 入手讲起, lab8 进一步更新了 sys_write / sys_read: 294 | 295 | ```c++ 296 | uint64 sys_write(int fd, uint64 va, uint64 len) { 297 | if (fd <= 2) { 298 | return console_write(va, len); 299 | } 300 | struct proc *p = curr_proc(); 301 | struct file *f = p->files[fd]; 302 | if (f->type == FD_PIPE) { 303 | return pipewrite(f->pipe, va, len); 304 | } else if (f->type == FD_INODE) { // 如果是一个磁盘文件 305 | return filewrite(f, va, len); 306 | } 307 | return -1; 308 | } 309 | 310 | uint64 sys_read(int fd, uint64 va, uint64 len) { 311 | if (fd <= 2) { 312 | return console_read(va, len); 313 | } 314 | struct proc *p = curr_proc(); 315 | struct file *f = p->files[fd]; 316 | if (f->type == FD_PIPE) { 317 | return piperead(f->pipe, va, len); 318 | } else if (f->type == FD_INODE) { // 如果是一个磁盘文件 319 | return fileread(f, va, len); 320 | } 321 | return -1; 322 | } 323 | ``` 324 | 325 | 现在我们来完成 `filewrite` 和 `fileread`,首先需要更新 lab6 中的 file 结构体定义,现在我们多了一种文件类型,除了预留给 0、1、2 的标准输入输出文件和 pipe 文件,多了一种 inode 文件,也就是磁盘文件: 326 | 327 | ```diff 328 | // file.h 329 | struct file { 330 | - enum { FD_NONE = 0, FD_PIPE} type; 331 | + enum { FD_NONE = 0, FD_PIPE, FD_INODE} type; 332 | int ref; // reference count 333 | char readable; 334 | char writable; 335 | struct pipe *pipe; // FD_PIPE 336 | + struct inode *ip; // FD_INODE 337 | uint off; 338 | }; 339 | ``` 340 | 341 | 对于 inode,为了解决共享问题(不同进程可以打开同一个磁盘文件),也有一个全局的 inode table,每当新打开一个文件的时候,会把一个空闲的 inode 绑定为对应 dinode 的缓存,这一步通过 `iget` 完成: 342 | 343 | ```c++ 344 | // 找到 inum 号 dinode 绑定的 inode,如果不存在新绑定一个 345 | static struct inode * 346 | iget(uint dev, uint inum) { 347 | struct inode *ip, *empty; 348 | // 遍历查找 inode table 349 | for (ip = &itable.inode[0]; ip < &itable.inode[NINODE]; ip++) { 350 | // 如果有对应的,引用计数 +1并返回 351 | if (ip->ref > 0 && ip->dev == dev && ip->inum == inum) { 352 | ip->ref++; 353 | return ip; 354 | } 355 | } 356 | // 如果没有对于的,找一个空闲 inode 完成绑定 357 | empty = find_empty() 358 | // GG,inode 表满了,果断自杀 359 | if (empty == 0) 360 | panic("iget: no inodes"); 361 | // 注意这里仅仅是写了元数据,没有实际读取,实际读取推迟到后面 362 | ip = empty; 363 | ip->dev = dev; 364 | ip->inum = inum; 365 | ip->ref = 1; 366 | ip->valid = 0; // 没有实际读取,valid = 0 367 | return ip; 368 | } 369 | ``` 370 | 371 | 当已经得到一个文件对应的 inode 后,可以通过 ivalid 函数确保其是有效的: 372 | 373 | ```c++ 374 | // Reads the inode from disk if necessary. 375 | void ivalid(struct inode *ip) { 376 | struct buf *bp; 377 | struct dinode *dip; 378 | if (ip->valid == 0) { 379 | // bread 可以完成一个块的读取,这个在将 buf 的时候说过了 380 | // IBLOCK 可以计算 inum 在几个 block 381 | bp = bread(ip->dev, IBLOCK(ip->inum, sb)); 382 | // 得到 dinode 内容 383 | dip = (struct dinode *) bp->data + ip->inum % IPB; 384 | // 完成实际读取 385 | ip->type = dip->type; 386 | ip->size = dip->size; 387 | memmove(ip->addrs, dip->addrs, sizeof(ip->addrs)); 388 | // buf 暂时没用了 389 | brelse(bp); 390 | // 现在有效了 391 | ip->valid = 1; 392 | } 393 | } 394 | ``` 395 | 396 | 在 inode 有效之后,可以通过 writei, readi 完成读写: 397 | 398 | ```c++ 399 | // 从 ip 对应文件读取 [off, off+n) 这一段数据到 dst 400 | int readi(struct inode *ip, char* dst, uint off, uint n) { 401 | uint tot, m; 402 | // 还记得 buf 吗? 403 | struct buf *bp; 404 | for (tot = 0; tot < n; tot += m, off += m, dst += m) { 405 | // bmap 完成 off 到 block num 的对应,见下 406 | bp = bread(ip->dev, bmap(ip, off / BSIZE)); 407 | // 一次最多读一个块,实际读取长度为 m 408 | m = MIN(n - tot, BSIZE - off % BSIZE); 409 | memmove(dst, (char*)bp->data + (off % BSIZE), m); 410 | brelse(bp); 411 | } 412 | return tot; 413 | } 414 | 415 | // 同 readi 416 | int writei(struct inode *ip, char* src, uint off, uint n) { 417 | uint tot, m; 418 | struct buf *bp; 419 | 420 | for (tot = 0; tot < n; tot += m, off += m, src += m) { 421 | bp = bread(ip->dev, bmap(ip, off / BSIZE)); 422 | m = MIN(n - tot, BSIZE - off % BSIZE); 423 | memmove(src, (char*)bp->data + (off % BSIZE), m); 424 | bwrite(bp); 425 | brelse(bp); 426 | } 427 | 428 | // 文件长度变长,需要更新 inode 里的 size 字段 429 | if (off > ip->size) 430 | ip->size = off; 431 | 432 | // 有可能 inode 信息被更新了,写回 433 | iupdate(ip); 434 | 435 | return tot; 436 | } 437 | ``` 438 | 439 | bmap 完成的功能很简单,但是我们支持了间接索引,同时还设计到文件大小的改变,所以也拉出来看看: 440 | 441 | ```c++ 442 | // bn = off / BSIZE 443 | uint bmap(struct inode *ip, uint bn) { 444 | uint addr, *a; 445 | struct buf *bp; 446 | // 如果 bn < 12,属于直接索引, block num = ip->addr[bn] 447 | if (bn < NDIRECT) { 448 | // 如果对应的 addr, 也就是 block num = 0,表明文件大小增加,需要给文件分配新的 data block 449 | // 这是通过 balloc 实现的,具体做法是在 bitmap 中找一个空闲 block,置位后返回其编号 450 | if ((addr = ip->addrs[bn]) == 0) 451 | ip->addrs[bn] = addr = balloc(ip->dev); 452 | return addr; 453 | } 454 | bn -= NDIRECT; 455 | // 间接索引块,那么对应的数据块就是一个大 addr 数组。 456 | if (bn < NINDIRECT) { 457 | // Load indirect block, allocating if necessary. 458 | if ((addr = ip->addrs[NDIRECT]) == 0) 459 | ip->addrs[NDIRECT] = addr = balloc(ip->dev); 460 | bp = bread(ip->dev, addr); 461 | a = (uint *) bp->data; 462 | if ((addr = a[bn]) == 0) { 463 | a[bn] = addr = balloc(ip->dev); 464 | bwrite(bp); 465 | } 466 | brelse(bp); 467 | return addr; 468 | } 469 | 470 | panic("bmap: out of range"); 471 | return 0; 472 | } 473 | ``` 474 | 475 | iupdate, balloc 等比较简单,同学们可以自行查看。值得一提的是,是的 `writei` 和 `readi` 考虑了数据来源是内核还是用户,多调用了一层 copyin/copyout,没有本质改变。 476 | 477 | 现在我们终于可以看看 filewrite 长啥样了: 478 | 479 | ```c++ 480 | uint64 filewrite(struct file* f, uint64 va, uint64 len) { 481 | int r; 482 | // 获得文件对应的 inode,不一定有效,但必须有元数据,元数据在 open 的时候写入 483 | ivalid(f->ip); 484 | // 注意,由于 writei 处理了 copyin,这里可以直接传用户 va 进去 485 | if ((r = writei(f->ip, 1, va, f->off, len)) > 0) 486 | f->off += r; // 注意这里移动了文件指针 487 | return r; 488 | } 489 | ``` 490 | 491 | `fileread` 同理,不在赘述。 492 | 493 | 你可能已经发现一个 bug,为啥莫名奇妙就有了 `inum`(作为 iget 的参数)?其实 `inum` 是在 open 一个 file 的时候获得的,open 其实非常复杂,要注意 open 有创建的语义,创建文件是比较复杂的内容,中涉及到了对 inode blocks 的改动。其实在 `writei` 的时候,有可能是的文件大小增加,这时就已经设计到了磁盘的修改。 494 | 495 | 还是从 syscall 开始: 496 | 497 | ```c++ 498 | #define O_RDONLY 0x000 // 只读 499 | #define O_WRONLY 0x001 // 只写 500 | #define O_RDWR 0x002 // 可读可写 501 | #define O_CREATE 0x200   // 如果不存在,创建 502 | #define O_TRUNC 0x400   // 舍弃原有内容,从头开始写 503 | 504 | uint64 sys_openat(uint64 va, uint64 omode, uint64 _flags) { 505 | struct proc *p = curr_proc(); 506 | char path[200]; 507 | copyinstr(p->pagetable, path, va, 200); 508 | return fileopen(path, omode); 509 | } 510 | ``` 511 | 512 | ``` c++ 513 | int fileopen(char *path, uint64 omode) { 514 | int fd; 515 | struct file *f; 516 | struct inode *ip; 517 | if (omode & O_CREATE) { 518 | // 新常见一个路径为 path 的文件 519 | ip = create(path, T_FILE); 520 | } else { 521 | // 尝试寻找一个路径为 path 的文件 522 | ip = namei(path); 523 | ivalid(ip); 524 | } 525 | // 还记得吗?从全局文件池和进程 fd 池中找一个空闲的出来,参考 lab6 526 | f = filealloc(); 527 | fd = fdalloc(f); 528 | // 初始化 529 | f->type = FD_INODE; 530 | f->off = 0; 531 | f->ip = ip; 532 | f->readable = !(omode & O_WRONLY); 533 | f->writable = (omode & O_WRONLY) || (omode & O_RDWR); 534 | if ((omode & O_TRUNC) && ip->type == T_FILE) { 535 | itrunc(ip); 536 | } 537 | return fd; 538 | } 539 | ``` 540 | 541 | 可见,核心函数其实是 `create` 和 `namei`, 后者比较简单,先来研究一下: 542 | 543 | ```c++ 544 | // namei = 获得根目录,然后在其中遍历查找 path 545 | struct inode *namei(char *path) { 546 | struct inode *dp = root_dir(); 547 | return dirlookup(dp, path, 0); 548 | } 549 | 550 | // root_dir 位置固定 551 | struct inode *root_dir() { 552 | struct inode* r = iget(ROOTDEV, ROOTINO); 553 | ivalid(r); 554 | return r; 555 | } 556 | 557 | // 便利根目录所有的 dirent,找到 name 一样的 inode 558 | struct inode * 559 | dirlookup(struct inode *dp, char *name, uint *poff) { 560 | uint off, inum; 561 | struct dirent de; 562 | // 每次迭代处理一个 block,注意根目录可能有多个 data block 563 | for (off = 0; off < dp->size; off += sizeof(de)) { 564 | readi(dp, 0, (uint64) &de, off, sizeof(de)); 565 | if (strncmp(name, de.name, DIRSIZ) == 0) { 566 | if (poff) 567 | *poff = off; 568 | inum = de.inum; 569 | // 找到之后,绑定一个内存 inode 然后返回 570 | return iget(dp->dev, inum); 571 | } 572 | } 573 | 574 | return 0; 575 | } 576 | ``` 577 | 578 | create 比较复杂,它长这样: 579 | 580 | ```c++ 581 | static struct inode * 582 | create(char *path, short type) { 583 | struct inode *ip, *dp; 584 | if(ip = namei(path) != 0) { 585 | // 已经存在,直接返回 586 | return ip; 587 | } 588 | // 创建一个文件,首先分配一个空闲的 disk inode, 绑定内存 inode 之后返回 589 | ip = ialloc(dp->dev, type); 590 | // 注意 ialloc 不会执行实际读取,必须有 ivalid 591 | ivalid(ip); 592 | // 在根目录创建一个 dirent 指向刚才创建的 inode 593 | dirlink(dp, path, ip->inum); 594 | // dp 不用了,iput 就是释放内存 inode,和 iget 正好相反。 595 | iput(dp); 596 | return ip; 597 | } 598 | ``` 599 | 600 | ialloc 干的事情:便利 inode blocks 找到一个空闲的,初始化并返回。dirlink 干的事情,便利根目录数据块,找到一个空的 dirent,设置 dirent = {inum, filename} 然后返回,注意这一步可能找不到空位,这是需要找一个新的数据块,并扩大 root_dir size,这是由 bmap 自动完成的。这两个函数就不做代码展示。 601 | 602 | fileopen 还可能会导致文件 truncate,也就是截断,具体做法是舍弃全部现有内容,释放所有 data block 并添加到 free bitmap 里。这也是目前 nfs 中唯一的文件变短方式。 603 | 604 | 最后一个剩余的操作是 fclose,其实 inode 文件的关闭只需要调用 iput 就好了,iput 的实现简单到让人感觉迷惑,就是 inode 引用计数减一。诶?为什么没有计数为 0 就写回然后释放 inode 的操作?和 buf 的释放同理,这里会等 inode 池满了之后自行被替换出去,重新读磁盘实在太太太太慢了。对了,千万记得 iput 和 iget 数量相同,一定要一一对应,否则你懂的。C 编程实在太危险了 QAQ,我感觉框架里打概率有泄漏。。没导致错误而已。 605 | 606 | ```c++ 607 | void 608 | fileclose(struct file *f) 609 | { 610 | if(--f->ref > 0) { 611 | return; 612 | } 613 | // 暂时不支持标准输入输出文件的关闭 614 | if(f->type == FD_PIPE){ 615 | pipeclose(f->pipe, f->writable); 616 | } else if(f->type == FD_INODE) { 617 | iput(f->ip); 618 | } 619 | 620 | f->off = 0; 621 | f->readable = 0; 622 | f->writable = 0; 623 | f->ref = 0; 624 | f->type = FD_NONE; 625 | } 626 | ``` 627 | ```c++ 628 | void iput(struct inode *ip) { 629 | ip->ref--; 630 | } 631 | ``` 632 | 633 | // TODO 总感觉讲完了,但又没有完全讲完,大家没搞懂的地方可以发个 issue 然后最后 wechat 再 cue 我一下 634 | 635 | ## 展望 636 | 637 | lab8 马上就位,在 lab8 中,我们将实现带参数的 exec, 实现从磁盘 load 文件来丢掉丑陋的 pack.py,支持 elf 解析来摆脱人为规定的地址,此外,还将支持标准文件的关闭和 sys_dup 来支持 IO 重定向。我们还将拥有一批用户态程序如 ls, echo, cat 等,是不是有点唬人了?虽然这些和 lab8 的要求并没有什么联系,emm,到时候就知道了。 -------------------------------------------------------------------------------- /lab8/exercise.md: -------------------------------------------------------------------------------- 1 | # lab8 实验要求 2 | 3 | * 本节难度:**对 OS 的全局理解要求较高** 4 | * 实验分为基础作业实验和扩展作业实验两项(可二选一) 5 | 6 | ## 基础作业 7 | 8 | **在保持 syscall 数量和基本含义不变的情况下,通过对 OS 内部的改进,提升 OS 的质量**。 9 | 10 | 同学们通过独立完成前面 7 个实验后,应该对于操作系统的核心关键机制有了较好的了解,并知道如何形成一个有进程 / 地址空间 / 文件核心概念的基本功能 OS。但同学自制的 OS 可能还需进一步完善,才能在功能 / 性能 / 可靠性上进一步进化,以使得 lab8 测试用例的正常运行。 11 | 12 | 第 8 个实验的目的是希望同学们能够在完成前 7 个实验的基础上,站在全局视角,分析 lab2~lab8 测试用例(没增加新的 syscall 访问,只是更加全面和深入地测试操作系统的质量和能力)的运行情况,分析和理解自己写的 OS 是否能比较好地满足应用需求?如果不满足应用需求,或者应用导致系统缓慢甚至崩溃,那原因出在哪里?应该如何修改?修改后的 OS 是否更加完善还是缺陷更多? 13 | 14 | ### 实验要求 15 | 16 | * 实现分支:ch8。 17 | 18 | - 运行 [lab8测例](https://github.com/DeathWish5/riscvos-c-tests/) ,观察并分析 ch8 中部分测试用例对 lab7 OS 造成的不良影响。 19 | - 结合你学到的操作系统课程知识和你的操作系统具体实践情况,分析你写的 lab7 OS 对 lab8 的 app 支持不好的原因,比如:为何没运行通过,为何死在某处了,为何系统崩溃,为何系统非常缓慢。分析可能的解决方法。(2~4 个,4 个合理的分析就可得到满分,超过 4 个不额外得分)。 20 | - 更进一步完成编程实现,使其可以通过一些原本 fail 的测例。(1~2 个,超过 2 个不额外得分)。 21 | 22 | ### 报告要求 23 | 24 | - 对于失败测例的现象观察,原因分析,并提出可能的解决思路(2~4个)。 25 | - 编程实现的具体内容,不需要贴出全部代码,重要的是描述清楚自己的思路和做法(1~2个)。 26 | - (optional)你对本次实验的其他看法。 27 | 28 | ### 其他说明: 29 | 30 | - 注意:编程实现部分的底线是 OS 不崩溃,如果你解决不了问题,就解决出问题的进程。可以通过简单杀死进程方式保证OS不会死掉。比如不支持某种 corner case,就把触发该 case 的进程杀掉,如果是这样,至少完成两个。会根据报告综合给分。 31 | - 有些测例属于非法程序,比如申请过量内存,对于这些程序,杀死进程其实就是正确的做法。感兴趣可以了解:[OOM killer](https://docs.memset.com/other/linux-s-oom-process-killer)。 32 | - 不一定所有的测例都会导致自己实现的 OS 崩溃,与语言和实现都有关系,选择出问题的测例分析即可。对于没有出错的测例,可以选择性分析自己的 OS 是如何预防这些"刁钻"测例的。对于测例没有测到的,也可以分析自己觉得安全 / 高效的实现,只要分析合理及给分。 33 | - 鼓励针对有趣的测例进行分析!开放思考! 34 | 35 | > 备注 36 | > 37 | > 1. **lab8 分值与前 7 个 lab 相同,截至是时间为 15 周周末,基础实验属于必做实验(除非你选择做扩展作业来代替基础作业)**。 38 | > 2. 在测例中有简明描述:想测试OS哪方面的质量。同学请量力而行,推荐不要超过上述上限。咱们不要卷。 39 | > 3. 对于有特殊要求的同学(比如你觉得上面的实验太难),可单独找助教或老师说出你感兴趣或力所能及的实验内容,得到老师和助教同意后,做你提出的实验。 40 | > 4. **欢迎同学们贡献新测例,有意义测例经过助教检查可以写进报告充当工作量,欢迎打掉框架代码OS,也欢迎打掉其他同学的OS**。 41 | 42 | ### 实验检查 43 | 44 | - 实验目录要求 45 | 46 | 目录要求不变(参考lab1目录或者示例代码目录结构)。同样在 os 目录下 `make run` 之后可以正确加载用户程序并执行。 47 | 48 | 加载的用户测例位置: `../user/build/elf`。 49 | 50 | - 检查 51 | 52 | 可以正确 `make run` 执行,可以正确执行目标用户测例,并得到预期输出(详见测例注释)。 53 | 54 | ### 问答作业 55 | 56 | 无 57 | 58 | 59 | 60 | ## 扩展作业(可选) 61 | 62 | 给部分同学不同的 OS 设计与实现的实验选择。扩展作业选项(1-14)基于 lab7 来实现,扩展作业选项(15)是发现目标内核(ch7 的 ucore / rcore os)漏洞。可选内容(有大致难度估计)如下: 63 | 64 | 1. 实现多核支持,设计多核相关测试用例,并通过已有和新的测试用例(难度:8) 65 | * 某学长的有 bug 的 rcore tutorial 参考实现: [https://github.com/xy-plus/rCore-Tutorial-v3/tree/ch7](https://github.com/xy-plus/rCore-Tutorial-v3/tree/ch7?fileGuid=gXqmevn42YSgQpqo) 66 | 2. 实现slab内存分配算法,通过相关测试用例(难度:7) 67 | * [https://github.com/tokio-rs/slab](https://github.com/tokio-rs/slab?fileGuid=gXqmevn42YSgQpqo) 68 | 3. 实现新的调度算法,如 CFS, BFS 等,通过相关测试用例(难度:7) 69 | * [https://en.wikipedia.org/wiki/Completely_Fair_Scheduler](https://en.wikipedia.org/wiki/Completely_Fair_Scheduler?fileGuid=gXqmevn42YSgQpqo) 70 | * [https://www.kernel.org/doc/html/latest/scheduler/sched-design-CFS.html](https://www.kernel.org/doc/html/latest/scheduler/sched-design-CFS.html?fileGuid=gXqmevn42YSgQpqo) 71 | 4. 实现某种 IO buffer 缓存替换算法,如 2Q, LRU-K,LIRS 等,通过相关测试用例(难度:6) 72 | * [LIRS: http://web.cse.ohio-state.edu/~zhang.574/lirs-sigmetrics-02.html](http://web.cse.ohio-state.edu/~zhang.574/lirs-sigmetrics-02.html?fileGuid=gXqmevn42YSgQpqo) 73 | * [2Q: https://nyuscholars.nyu.edu/en/publications/2q-a-low-overhead-high-performance-buffer-replacement-algorithm](https://nyuscholars.nyu.edu/en/publications/2q-a-low-overhead-high-performance-buffer-replacement-algorithm?fileGuid=gXqmevn42YSgQpqo) 74 | * [LRU-K: https://dl.acm.org/doi/10.1145/170036.170081](https://dl.acm.org/doi/10.1145/170036.170081?fileGuid=gXqmevn42YSgQpqo) 75 | 5. 实现某种页替换算法,如 Clock, 二次机会算法等,通过相关测试用例(难度:6) 76 | 6. 实现支持日志机制的可靠文件系统,可参考 OSTEP 教材中对日志文件系统的描述(难度:7) 77 | 7. 支持 virtio disk 的中断机制,提高 IO 性能(难度:4) 78 | * [chapter8 https://github.com/rcore-os/rCore-Tutorial-Book-v3/tree/chy](https://github.com/rcore-os/rCore-Tutorial-Book-v3/tree/chy?fileGuid=gXqmevn42YSgQpqo) 79 | * [https://github.com/rcore-os/virtio-drivers](https://github.com/rcore-os/virtio-drivers?fileGuid=gXqmevn42YSgQpqo) 80 | * [https://github.com/belowthetree/TisuOS](https://github.com/belowthetree/TisuOS?fileGuid=gXqmevn42YSgQpqo) 81 | 8. 支持 virtio framebuffer /键盘/鼠标处理,给出demo(推荐类似 pong 的 graphic game)的测试用例(难度:7) 82 | * code:[https://github.com/sgmarz/osblog/tree/pong](https://github.com/sgmarz/osblog/tree/pong?fileGuid=gXqmevn42YSgQpqo) 83 | * code:[https://github.com/belowthetree/TisuOS](https://github.com/belowthetree/TisuOS?fileGuid=gXqmevn42YSgQpqo) 84 | * [tutorial doc: Talking with our new Operating System by Handling Input Events and Devices](https://blog.stephenmarz.com/2020/08/03/risc-v-os-using-rust-input-devices/?fileGuid=gXqmevn42YSgQpqo) 85 | * [tutorial doc: Getting Graphical Output from our Custom RISC-V Operating System in Rust](https://blog.stephenmarz.com/2020/11/11/risc-v-os-using-rust-graphics/?fileGuid=gXqmevn42YSgQpqo) 86 | * [tutorial doc: Writing Pong Game in Rust for my OS Written in Rust](https://blog.stephenmarz.com/category/os/?fileGuid=gXqmevn42YSgQpqo) 87 | 9. 支持 virtio NIC,给出测试用例(难度:7) 88 | * [https://github.com/rcore-os/virtio-drivers](https://github.com/rcore-os/virtio-drivers?fileGuid=gXqmevn42YSgQpqo) 89 | 10. 支持 virtio fs or其他 virtio 虚拟外设,通过测试用例(难度:5) 90 | * [https://docs.oasis-open.org/virtio/virtio/v1.1/csprd01/virtio-v1.1-csprd01.html](https://docs.oasis-open.org/virtio/virtio/v1.1/csprd01/virtio-v1.1-csprd01.html?fileGuid=gXqmevn42YSgQpqo) 91 | 11. 支持[testsuits for kernel ](https://gitee.com/oscomp/testsuits-for-oskernel#testsuits-for-os-kernel?fileGuid=gXqmevn42YSgQpqo)中15个以上的 syscall,通过相关测试用例(难度:6) 92 | * 大部分与我们实验涉及的 syscall 类似 93 | * [https://gitee.com/oscomp/testsuits-for-oskernel#testsuits-for-os-kernel](https://gitee.com/oscomp/testsuits-for-oskernel#testsuits-for-os-kernel?fileGuid=gXqmevn42YSgQpqo) 94 | 12. 支持新文件系统,比如 fat32 或 ext2 等,通过相关测试用例(难度:7) 95 | * [https://github.com/rafalh/rust-fatfs](https://github.com/rafalh/rust-fatfs?fileGuid=gXqmevn42YSgQpqo) 96 | * [https://github.com/pi-pi3/ext2-rs](https://github.com/pi-pi3/ext2-rs?fileGuid=gXqmevn42YSgQpqo) 97 | 13. 支持物理硬件(如全志哪吒开发板,K210开发板等)(难度8) 98 | * 可找老师要物理硬件开发板和相关开发资料 99 | 14. 对 fork / exec / spawn 等进行扩展,并改进shell程序,实现“|”这种经典的管道机制。(难度:4) 100 | * 参考 rcore tutorial 文档中 chapter7 中内容 101 | 15. 向实验用操作系统发起fuzzing攻击(难度:?) 102 | * 其实助教或老师写出的OS kernel也是漏洞百出,不堪一击。我们缺少的仅仅是一个可以方便发现 bug 的工具。也许同学们能写出或改造出一个 os kernel fuzzing 工具来发现并crash它/它们。下面的仅仅是参考,应该还不能直接用,也许能给你一些启发。 103 | * [gustave fuzzer for os kernel tutorial](https://github.com/airbus-seclab/gustave/blob/master/doc/tutorial.md?fileGuid=gXqmevn42YSgQpqo) 104 | * [gustave fuzzer project](https://github.com/airbus-seclab/gustave?fileGuid=gXqmevn42YSgQpqo) 105 | * [paper: GUSTAVE: Fuzzing OS kernels like simple applications](https://airbus-seclab.github.io/GUSTAVE_thcon/GUSTAVE_thcon.pdf?fileGuid=gXqmevn42YSgQpqo) 106 | 16. **学生自己的想法,但需要告知老师或助教,并得到同意。** 107 | 108 | * 支持 1~3 人组队,如果确定并组队完成,请在5月2日前通过电子邮件告知助教。成员的具体得分可能会通过与老师和助教的当面交流综合判断给出。 尽量减少划水与抱大腿。 109 | * 根据老师和助教的评价,可获得额外得分,但不会超过实验 的满分(30分)。也就是如果前面实验有失分,可以通过一个简单扩展把部分分数拿回来。 110 | 111 | > 备注 112 | > 113 | > 1. 不能抄袭其他上课同学的作业,查出后,**所有实验成绩清零。** 114 | > 115 | > 2. lab8扩展作业可代替lab8基础作业。拓展实验给分要求会远低于大实验,简单的拓展也可以的得到较高的评价。在完成代码的同时,也要求写出有关设计思路,问题及解决方法,实验分析等内容的实验报告。 116 | > 117 | > 3. 完成lab1~lab8的编程作业也可得满分。这个扩展作业不是必须要做的,是给有兴趣但不想选择大实验的同学一个选择。 118 | 119 | ### 实验检查 120 | 121 | 完成后当面交流。 122 | 123 | ### 问答作业 124 | 125 | 无 -------------------------------------------------------------------------------- /lab8/guide.md: -------------------------------------------------------------------------------- 1 | # 还不知道是啥 2 | 3 | lab8 guide 应该是对 ch7-plus 的解释,比较难写,暂时还没有。。。短期内估计不会有了😣😭😭😭。 4 | 5 | 可以基于 ch7 分支进行 lab8 实验,也可以基于更加强大的 ch7-plus(支持:elf 解析,磁盘加载文件,IO 重定向,进程初始参数和环境变量,用户模式常用程序),但后者还有 bug 尚未解决 QAQ,预计完善要等五一假期之后了。。。😞 6 | 7 | 有意愿加入框架代码或者文档开发工作的同学可以联系助教,视工作量可以替代 lab8 实验。 -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ucore-Tutorial-Book 2 | 3 | 该文档为清华大学计算机系2021春本科生操作系统课程C语言实验指导。旨在帮助同学们理解 [ucore-Tutorial](https://github.com/DeathWish5/ucore-Tutorial) 代码。 4 | 5 | 每个目录中 guide.md 为示例代码解释,请结合代码理解文档,结合文档理解代码,exericse.md 为练习要求。 6 | 7 | 文档会在作业 DDL 一周之前发布,文档未开发完时,可以先参考 rust 文档,二者只有语言不同,其他内容理论上将保持一致。 8 | 9 | 该文档目前尚不完善,仅为简单的速成指导,请结合其他文档了解更多细节和理论知识。 10 | 11 | * [rCore-Tutorial-Book-v3](https://rcore-os.github.io/rCore-Tutorial-Book-v3/index.html) 12 | * [ucore-rv64-Book](https://nankai.gitbook.io/ucore-os-on-risc-v64) 13 | 14 | 由于开发周期较短,难免犯错,如果发现任何错误,欢迎您及时指出,十分感谢。 15 | 16 | 17 | 18 | --------------------------------------------------------------------------------