├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── all.sh ├── make.bat ├── outline.md ├── requirements.txt ├── show.sh └── source ├── _static ├── dracula.css └── my_style.css ├── appendix-b └── index.rst ├── appendix-c └── index.rst ├── appendix-d ├── 1asm.rst ├── 2rv.rst └── index.rst ├── chapter0 ├── 1setup-devel-env.rst └── index.rst ├── chapter1 ├── 0intro.rst ├── 1app-ee-platform.rst ├── 2remove-std.rst ├── 3exercise.rst ├── CallStack.png ├── MemoryLayout.png ├── StackFrame.png ├── app-software-stack.png ├── ch1-demo.png ├── ch1.py ├── chap1-intro.png ├── color-demo.png ├── function-call.png └── index.rst ├── chapter2 ├── 0intro.rst ├── 1rv-privilege.rst ├── 2application.rst ├── 3batch-system.rst ├── 4exercise.rst ├── EnvironmentCallFlow.png ├── PrivilegeStack.png ├── ch2.py ├── deng-fish.png └── index.rst ├── chapter3 ├── 0intro.rst ├── 1multi-loader.rst ├── 2proc-basic.rst ├── 3multiprogramming.rst ├── 4time-sharing-system.rst ├── 5exercise.rst ├── fsm-coop.png ├── index.rst ├── multiprogramming.png ├── switch-1.png ├── switch-2.png └── task_context.png ├── chapter4 ├── 0intro.rst ├── 1rust-dynamic-allocation.rst ├── 2address-space.rst ├── 3sv39-implementation-1.rst ├── 4sv39-implementation-2.rst ├── 5kernel-app-spaces.rst ├── 6multitasking-based-on-as.rst ├── 7exercise.rst ├── address-translation.png ├── app-as-full.png ├── index.rst ├── kernel-as-high.png ├── kernel-as-low.png ├── linear-table.png ├── page-table.png ├── pte-rwx.png ├── rust-containers.png ├── satp.png ├── segmentation.png ├── simple-base-bound.png ├── sv39-full.png ├── sv39-pte.png ├── sv39-va-pa.png ├── trie-1.png └── trie.png ├── chapter5 ├── 0intro.rst ├── 1process.rst ├── 2core-data-structures.rst ├── 3shell-and-binloader.rst ├── 4exercise.rst └── index.rst ├── chapter6 ├── 0intro.rst ├── 1fs-interface.rst ├── 2fs-implementation.rst ├── 3disk-based-loader ├── 4exec-with-argv ├── 5exercise.rst ├── ch6.pptx ├── index.rst └── user-stack-cmdargs.png ├── chapter7 ├── 0intro.rst ├── 1file-descriptor.rst ├── 2pipe.rst ├── 3exercise.rst └── index.rst ├── chapter8 ├── 0intro.rst ├── 1thread-kernel.rst ├── 2lock.rst ├── 3semaphore.rst ├── 4condition-variable.rst ├── 5exercise.rst └── index.rst ├── conf.py ├── index.rst ├── pygments-coloring.txt ├── resources └── test.gif ├── rest-example.rst ├── setup-sphinx.rst └── terminology.rst /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [main, dev] 6 | 7 | jobs: 8 | deploy-doc: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: "3.8.10" 16 | - name: Install dependencies 17 | run: pip install -r requirements.txt 18 | 19 | - name: build doc 20 | run: make html 21 | 22 | - name: create .nojekyll 23 | run: touch build/html/.nojekyll 24 | 25 | - name: Push to gh-pages 26 | uses: peaceiris/actions-gh-pages@v3 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | publish_dir: ./build/html -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | .vscode/ 3 | .idea 4 | source/_build/ 5 | .venv/ 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile deploy 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | view: 23 | make html && firefox build/html/index.html 24 | 25 | deploy: 26 | @make clean 27 | @make html 28 | @rm -rf docs 29 | @cp -r build/html docs 30 | @touch docs/.nojekyll 31 | @git add -A 32 | @git commit -m "Deploy" 33 | @git push 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uCore-Tutorial-Guide-2022Spring 2 | Documentation of uCore-Tutorial-Guide-2022Spring 3 | 4 | Deployed version can be found [here](https://LearningOS.github.io/uCore-Tutorial-Guide-2022S/). 5 | -------------------------------------------------------------------------------- /all.sh: -------------------------------------------------------------------------------- 1 | make clean && make html && google-chrome build/html/index.html 2 | 3 | -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /outline.md: -------------------------------------------------------------------------------- 1 | # 更新记录 2 | ### 2021-01-04更新: 3 | 4 | Chaptter4 添加实现的过程描述:改进内存隔离的好处; 5 | 6 | ### 2020-12-20更新: 7 | 8 | 将文件描述符从 Chapter7 移动到 Chapter6。 9 | 10 | ### 2020-12-02更新: 11 | 12 | 根据讨论更新了 Chapter1-Chapter7 到分割线之前的内容作为 Tutorial 的第一部分,即让系统能够将所有的资源都利用起来。第二部分则讨论如何做的更好。在 12 月 26 日之前尽可能按照大纲完成多个不同版本的 demo。 13 | 14 | [https://shimo.im/sheets/wV3VVxl04EieK3y1/MODOC](https://shimo.im/sheets/wV3VVxl04EieK3y1/MODOC)是目前的系统调用一览表,预计只需要实现 14 个系统调用就能初步满足要求。 15 | 16 | ### 2020-11-30更新: 17 | 18 | 更新了Chapter2。 19 | 20 | 合并了Chapter3/Chapter4为Chapter3,目前覆盖范围为Chapter1-Chapter5。 21 | 22 | ### lab 设计:2020-11-01 23 | 24 | #### 可能的章节与代码风格 25 | 26 | * 新 OS 实验的目的是:“**强化学生对 OS 的整体观念**”。OS的目的是满足应用需求,为此需要一定的硬件支持和自身逐步增强的功能。鼓励学生自己从头写(有参考实现)、强化整体观、step-by-step。 27 | * **整个文档的风格是应用**导向的,每个 step 的任务一定不是凭空而来、而是**应用**的需求。每一章都是为了解决一个应用具体需求而要求OS要完成的功能,这个功能需要一定的硬件支持。 28 | * 每个章节给出完整可运行且带有完整注释(可以通过 rustdoc 工具生成 html 版)的代码。 29 | * 文档中给出重要的代码片段(照顾到纸质版的读者,事实上在网页版给出代码的链接即可)而并不需要完整的代码,但是需要有完整的执行流程叙述,对于边界条件有足够的讨论。在文档中插入的代码不带有注释,而是将解释放到文档的文字部分。 30 | * 类似xv6,每一章的小节描述一项小功能是如何实现的,不同小节之间可能有一定的先后关系,也有可能是并列的。 31 | * 尽可能讲清楚设计背后的思想与优缺点。 32 | * 在讲解OS设计方面,尽量做到与语言无关。在讲解例子的时候,应该有对应的C和rust版本。 33 | * 在某些具体例子中,最好能体现rust比c强 34 | * 2020-10-28:前几章 Chapter1-4 需要等具体实现出来之后再规划章节。 35 | 36 | # 章节大纲 37 | ## Chapter0 Hello world! 之旅(偏概述) 38 | 39 | ### 主要动机: 40 | 41 | 参考 csapp 第一章,站在一个相对宏观的视角解释一个非常简单的 hello world! 程序是在哪些硬件/软件的支持下得以编译/运行起来的。 42 | 43 | helloworld.c 如何被编译器编译成执行程序,且如何被操作系统执行的。 44 | 45 | gcc 46 | 47 | strace 48 | 49 | ## Chapter1 裸机应用(优先级1) 50 | 51 | ### 主要动机 52 | 53 | 支持应用进行计算与结果输出。 54 | 55 | 在裸机上输出 Hello world,就像在其他 OS 上一样。 56 | 57 | app列表: 58 | 59 | * hello_world:输出字符串 60 | * count_sum:累加一维数组的和,并输出结果 61 | 62 | 备注:不需要输入功能 63 | 64 | ### 内核应完成功能 65 | 66 | 内存地址空间: 67 | 68 | 知道自己在内存的哪个位置。理解编译器生成的代码。 69 | 70 | init:基本初始化 71 | 72 | 主要是硬件加电后的硬件初始化,以前是OS做,后面给BIOS, bootloader等完成初步初始化。OS需要知道内存大小,IO分布。 73 | 74 | write函数:输出字符串 75 | 76 | 驱动串口的初始化,能够通过串口输出。 77 | 78 | exit函数:表明程序结束 79 | 80 | 其它(不是主要的): 81 | 82 | 在 qemu/k210 平台上基于 RustSBI 跳转到内核,打印调试信息,支持内核堆内存分配。 83 | 84 | ### 章节分布 85 | 86 | 基本上和第二版/第三版一致。注意需要考虑上面的应用和功能。 87 | 88 | ## Chapter2批处理系统(优先级1) 89 | 90 | ### 主要动机 91 | 92 | 内核不会被应用程序破坏 93 | 94 | ### 用户程序 95 | 96 | 支持应用进行计算与结果输出。在裸机上输出 Hello world,就像在其他 OS 上一样。但应用程序无法破坏内核,但能得到内核的服务。 97 | 98 | app列表: 99 | 100 | * hello_world:输出字符串。 101 | * count_sum:累加一维数组的和,并输出结果。 102 | ### 内核应完成功能 103 | 104 | 设置好内核和用户运行的栈,内核初始化完成后通过 sret 跳转到用户程序进行执行,然后在用户程序系统调用的时候完成特权级切换、上下文保存/恢复及栈的切换 105 | 106 | 按顺序加载运行多个应用程序。当应用程序出错(非法指令基于 RustSBI 不容易完成,比如访问非法的物理地址)之后直接杀死应用程序并切换到下一个。 107 | 108 | ### 新增系统调用 109 | 110 | * sys_write:向串口写 111 | * sys_exit: 表明任务结束。 112 | ### 实现备注 113 | 114 | 将编译之后的用户镜像和内核打包到一起放到内存上 115 | 116 | 分离用户和内核特权级,保护OS,用户需要请求内核提供的服务 117 | 118 | ## 119 | ## Chapter3 分时多任务系统之一非抢占式调度(优先级1) 120 | 121 | ### 主要动机 122 | 123 | 提高整个应用的CPU利用率 124 | 125 | 多任务,因此需要实现任务切换,可采用如下方法: 126 | 127 | * 批处理:在内存中放多个程序,执行完一个再执行下一个。当执行IO操作时,采用的是忙等的方式,效率差。 128 | * 非抢占切换:CPU和I/O设备之间速度不匹配矛盾,程序之间的公平性。当一个程序主动要求暂停或退出时,换另外一个程序执行CPU计算。 129 | 130 | *>> 这时,可能需要引入中断(但中断不是本章主要的内容,如果不引入更好)。* 131 | 132 | ### 用户程序 133 | 134 | 两个程序放置在一个不同的固定的物理地址上(这样不需要页表机制等虚存能力),完成的功能为:一个程序完成一些计算&输出,主动暂停,OS切换到另外一个程序执行,交替运行。 135 | 136 | * count_multiplication:一维数组的乘法,并输出结果 137 | * count_sum:累加一维数组的和,并输出结果 138 | * [wyf 的具体实现]三个输出小程序,详见[here](https://github.com/rcore-os/rCore-Tutorial-v3/tree/ch3-coop/user/src/bin) 139 | ### 内核应完成功能 140 | 141 | 实现通过 sys_yield 交出当前任务的 CPU 所有权,通过 sys_exit 表明任务结束。需要为每个任务分配一个用户栈和内核栈,且需要实现类似 switch 用来任务切换的函数。 142 | 143 | * sys_yield:让出CPU 144 | * sys_exit:退出当前任务并让出 CPU 145 | ### 实现备注 146 | 147 | 重点是实现switch 148 | 149 | 当所有任务运行结束后退出内核 150 | 151 | ## Chapter3 分时多任务系统之二 抢占式调度(优先级1) 152 | 153 | ### 主要动机 154 | 155 | 进一步提高整个应用的CPU利用率/交互性与任务之间的公平性 156 | 157 | 因此需要实现强制任务切换,并引入中断,可采用如下方法: 158 | 159 | * 时钟中断:基于时间片进行调度 160 | * (不在这里引入)串口中断:在发出输出请求后,不是轮询忙等,而是中断方式响应 161 | ### 用户程序 162 | 163 | * [wyf 的具体实现]三个计算质数幂次的小程序,外加一个 sleep 的程序。[here](https://github.com/rcore-os/rCore-Tutorial-v3/tree/ch3/user/src/bin) 164 | ### 内核应完成功能 165 | 166 | 实现时钟/串口中断处理,以及基于中断的基本时间片轮转调度 167 | 168 | ### 新增系统调用 169 | 170 | * sys_get_time:返回当前的 CPU 时钟周期数 171 | ## Chapter4 内存隔离安全性:地址空间(优先级1) 172 | 173 | ### 主要动机 174 | 175 | * 更好地支持应用(包括内核)的动态内存需求。首先:在内核态实现动态内存分配(这是物理内存),这样引入了堆的概念 176 | * 更好地支持在内核中对非法地址的访问的检查。在内核态实现页表机制,这样内核访问异常地址也能及时报警。 177 | * 提高应用间的安全性(通过页机制实现隔离) 178 | * 附带好处:应用程序地址空间可以相同,便于应用程序的开发 179 | ### 用户程序 180 | 181 | 应用程序与上一章基本相同,只不过应用程序的地址空间起始位置应该相同。而且这一章需要将 ELF 链接进内核而不是二进制镜像。 182 | 183 | 特别的,可以设置访问其他应用程序地址空间或是访问内核地址空间的应用程序,内核会将其杀死。 184 | 185 | 在用户库使用 sbrk 申请动态分配空间而不是放在数据段中。 186 | 187 | ### 内核应完成功能 188 | 189 | * 内核动态内存分配器(对于 Rust 而言,对于 C 仍可以考虑静态分配) 190 | * 物理页帧分配器 191 | * 页表机制,特别是用户和内核地址空间的隔离(参考 xv6) 192 | * ELF 解析和加载(在内核初始化的时候完成全部的地址空间创建和加载即可) 193 | ### 新增系统调用 194 | 195 | * sys_sbrk:拓展或缩减当前应用程序的堆空间大小 196 | ### 建议实现过程: 197 | 198 | 1. 在Chapter1的基础上实现基本的物理内存管理机制,即连续内存的动态分配。 199 | 2. 在Chapter1的基础上实现基本的页表机制。 200 | 3. 然后再合并到Chapter3上。 201 | ## Chapter5 进程及重要系统调用(优先级1) 202 | 203 | ### 主要动机 204 | 205 | 应用以进程的方式进行运行,简化了应用开发的负担,OS也更好管理 206 | 207 | 引入重要的进程概念,整合Chapt1~4的内容抽象出进程,实现一系列相关机制及 syscall 208 | 209 | ### 用户程序 210 | 211 | shell程序 user_shell以及一些相应的测试 212 | 213 | ### 内核应完成功能 214 | 215 | 实现完整的子进程机制,初始化第一个用户进程 initproc。 216 | 217 | ### 新增系统调用 218 | 219 | * sys_fork 220 | * sys_wait(轮询版) 221 | * sys_exec 222 | * sys_getpid 223 | * sys_yield更新 224 | * sys_exit 更新 225 | * sys_read:终端需要从串口读取命令 226 | ## Chapter6 文件系统与进程间通信(优先级1) 227 | 228 | ### 主要动机 229 | 230 | 进程之间需要进行一些协作。本章主要是通过管道进行通信。 231 | 232 | 同时,需要引入文件系统,并通过文件描述符来访问对应类型的 Unix 资源。 233 | 234 | ### 用户程序 235 | 236 | 简单的通过 fork 和子进程共享管道的测试; 237 | 238 | 【可选】强化shell程序的功能,支持使用 | 进行管道连接。 239 | 240 | ### 内核应完成功能 241 | 242 | 实现管道。 243 | 244 | 将字符设备(标准输入/输出)和管道封装为通过文件描述符访问的文件。 245 | 246 | ### 新增系统调用 247 | 248 | * sys_pipe:目前对于管道的 read/write 只需实现轮询版本。 249 | * sys_close:作用是关闭管道 250 | ## Chapter7 数据持久化(优先级1) 251 | 252 | ### 主要动机 253 | 254 | 实现数据持久化存储。 255 | 256 | ### 用户程序 257 | 258 | 多种不同大小的文件读写。 259 | 260 | ### 内核应完成功能 261 | 262 | 实现另一种在块设备上持久化存储的文件。 263 | 264 | 文件系统不需要实现目录。 265 | 266 | ### 新增系统调用 267 | 268 | * sys_open:创建或打开一个文件 269 | # ----------------------------分割线------------------------------------------------- 270 | 271 | ## Chapter6 单核同步互斥(优先级1,需要划分为单核/多核两部分) 272 | 273 | ### 主要动机: 274 | 275 | 应用之间需要在操作系统的帮助下有序共享资源(如串口,内存等)。 276 | 277 | 解释内核中已有的同步互斥问题,并实现阻塞机制。 278 | 279 | ### 内核应完成功能: 280 | 281 | 实现死锁检测机制,并基于阻塞机制实现 sys_sleep 和 sys_wait 以及 sys_kill 282 | 283 | ### 新增系统调用: 284 | 285 | sys_sleep 以及 sys_wait/sys_kill 的更新 286 | 287 | ### 章节分布: 288 | 289 | #### 基于原子指令实现自旋锁 290 | 291 | * 讨论并发冲突的来源(单核/多核) 292 | * 关中断/自旋/自旋关中断锁各自什么情况下能起作用,在课上还讲到一种获取锁失败直接 yield 的锁 293 | * 原子指令与内存一致性模型简介 294 | * 具体实现 295 | * 需要说明的是,课上的锁是针对于同一时刻只能有一个进程处于临界区之内。但是 Rust 风格的锁,也就是 Mutex 更加类似于一个管程(尽管 Rust 语言并没有这个概念),它用来保护一个数据结构,保证同一时间只有一个进程对于这个数据结构进行操作,自然保证了一致性。而 xv6 里面的锁只能保护临界区,相对而言对于数据结构一致性的保护就需要更加复杂的讨论。 296 | #### 死锁检测 297 | 298 | #### 阻塞的同步原语:条件变量 299 | 300 | 简单讨论一下其他的同步原语。 301 | 302 | * 课上提到的信号量和互斥量(后者是前者的特例)保护的都是某一个临界区 303 | #### 基于条件变量实现 sys_sleep 304 | 305 | #### 基于条件变量重新实现 sys_wait 306 | 307 | #### 更新 sys_kill 使得支持 kill 掉正在阻塞的进程 308 | 309 | ## ChapterX IPC(优先级1) 310 | 311 | ### 主要动机: 312 | 313 | 应用之间需要交换信息 314 | 315 | ### 内核应完成功能: 316 | 317 | * pipe 318 | * shared mem 319 | ### 新增系统调用: 320 | 321 | ## Chapter8 设备驱动(优先级2) 322 | 323 | ### 主要动机: 324 | 325 | 应用可以把I/O 设备用起来。 326 | 327 | ### 内核应完成功能: 328 | 329 | 实现块设备驱动和串口驱动,理解同步/异步两种驱动实现方式 330 | 331 | #### 背景知识:设备驱动、设备寄存器、轮询、中断 332 | 333 | #### 设备树(可选) 334 | 335 | #### 实现 virtio_disk 块设备的块读写(同步+轮询风格) 336 | 337 | #### 实现 virtio_disk 块设备的块读写(异步+中断风格) 338 | 339 | #### 实现串口设备的异步输入和同步输出 340 | 341 | * 参考 xv6,可以在内核里面维护一个 FIFO,这样即使串口本身没有 FIFO 也可以 342 | ## Chapter9 Unix 资源:文件(优先级1) 343 | 344 | ### 主要动机: 345 | 346 | 应用可以通过单一接口(文件)访问磁盘来保存信息和访问其他外设 347 | 348 | Unix 万物皆文件,将文件作为进程可以访问的内核资源单位 349 | 350 | ### 内核应完成功能: 351 | 352 | 支持三种不同的 Unix 资源:字符设备(串口)、块设备(文件系统)、管道 353 | 354 | ### 新增系统调用: 355 | 356 | sys_open/sys_close 357 | 358 | ### 背景知识:Unix 万物皆文件/进程对于文件的访问方式 359 | 360 | #### file 抽象接口 361 | 362 | * 支持 read/write 两种操作,表示 file 到地址空间中一块缓冲区的读写操作 363 | #### 字符设备路线 364 | 365 | * 直接将串口设备驱动封装一下即可。 366 | #### 文件系统路线 367 | 368 | * 分成多个子章节,等实现出来之后才知道怎么写 369 | #### 管道路线 370 | 371 | * 一个非常经典的读者/写者问题。 372 | ### ChapterX 虚存管理(优先级2) 373 | 374 | ### 主要动机: 375 | 376 | 提高应用执行的效率(侧重内存) 377 | 378 | - 支持物理内存不够的情况 379 | 380 | - copy on write 381 | 382 | ### 内核应完成功能: 383 | 384 | ### 新增系统调用: 385 | 386 | 387 | 388 | ### Chapter10 多核(可选) 389 | 390 | ### 主要动机: 391 | 392 | 提高应用执行的并行执行效率(侧重多处理器) 393 | 394 | ### 内核应完成功能: 395 | 396 | ### 新增系统调用: 397 | 398 | #### 多核启动与 IPI 399 | 400 | #### 多核调度 401 | 402 | ### Chapter11多核下的同步互斥(可选) 403 | 404 | ### 主要动机: 405 | 406 | 提高应用并行执行下的正确性(侧重多处理器) 407 | 408 | ### 内核应完成功能: 409 | 410 | ### 新增系统调用: 411 | 412 | #### 多核启动与 IPI 413 | 414 | #### 多核调度 415 | 416 | ## Appendix A Rust 语言快速入门与练习题 417 | 418 | ## Appendix B 常见构建工具的使用方法 419 | 420 | 比如 Makefile\ld 等。 421 | 422 | ## Appendix C RustSBI 与 Kendryte K210 兼容性设计 423 | 424 | ## 其他附录… 425 | 426 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alabaster==0.7.12 2 | Babel==2.9.1 3 | certifi==2021.5.30 4 | charset-normalizer==2.0.4 5 | docutils==0.16 6 | idna==3.2 7 | imagesize==1.2.0 8 | jieba==0.42.1 9 | Jinja2==3.0.1 10 | MarkupSafe==2.0.1 11 | packaging==21.0 12 | Pygments==2.10.0 13 | pyparsing==2.4.7 14 | pytz==2021.1 15 | requests==2.26.0 16 | snowballstemmer==2.1.0 17 | Sphinx==4.1.2 18 | sphinx-comments==0.0.3 19 | sphinx-rtd-theme==0.5.2 20 | sphinx-tabs==3.2.0 21 | sphinxcontrib-applehelp==1.0.2 22 | sphinxcontrib-devhelp==1.0.2 23 | sphinxcontrib-htmlhelp==2.0.0 24 | sphinxcontrib-jsmath==1.0.1 25 | sphinxcontrib-qthelp==1.0.3 26 | sphinxcontrib-serializinghtml==1.1.5 27 | urllib3==1.26.6 28 | furo==2021.8.31 29 | -------------------------------------------------------------------------------- /show.sh: -------------------------------------------------------------------------------- 1 | make html && google-chrome build/html/index.html 2 | 3 | -------------------------------------------------------------------------------- /source/_static/dracula.css: -------------------------------------------------------------------------------- 1 | /* Dracula Theme v1.2.5 2 | * 3 | * https://github.com/zenorocha/dracula-theme 4 | * 5 | * Copyright 2016, All rights reserved 6 | * 7 | * Code licensed under the MIT license 8 | * http://zenorocha.mit-license.org 9 | * 10 | * @author Rob G 11 | * @author Chris Bracco 12 | * @author Zeno Rocha 13 | */ 14 | 15 | .highlight .hll { background-color: #111110 } 16 | .highlight { background: #282a36; color: #f8f8f2 } 17 | .highlight .c { color: #6272a4 } /* Comment */ 18 | .highlight .err { color: #f8f8f2 } /* Error */ 19 | .highlight .g { color: #f8f8f2 } /* Generic */ 20 | .highlight .k { color: #ff79c6 } /* Keyword */ 21 | .highlight .l { color: #f8f8f2 } /* Literal */ 22 | .highlight .n { color: #f8f8f2 } /* Name */ 23 | .highlight .o { color: #ff79c6 } /* Operator */ 24 | .highlight .x { color: #f8f8f2 } /* Other */ 25 | .highlight .p { color: #f8f8f2 } /* Punctuation */ 26 | .highlight .ch { color: #6272a4 } /* Comment.Hashbang */ 27 | .highlight .cm { color: #6272a4 } /* Comment.Multiline */ 28 | .highlight .cp { color: #ff79c6 } /* Comment.Preproc */ 29 | .highlight .cpf { color: #6272a4 } /* Comment.PreprocFile */ 30 | .highlight .c1 { color: #6272a4 } /* Comment.Single */ 31 | .highlight .cs { color: #6272a4 } /* Comment.Special */ 32 | .highlight .gd { color: #962e2f } /* Generic.Deleted */ 33 | .highlight .ge { color: #f8f8f2; text-decoration: underline } /* Generic.Emph */ 34 | .highlight .gr { color: #f8f8f2 } /* Generic.Error */ 35 | .highlight .gh { color: #f8f8f2; font-weight: bold } /* Generic.Heading */ 36 | .highlight .gi { color: #f8f8f2; font-weight: bold } /* Generic.Inserted */ 37 | .highlight .go { color: #44475a } /* Generic.Output */ 38 | .highlight .gp { color: #f8f8f2 } /* Generic.Prompt */ 39 | .highlight .gs { color: #f8f8f2 } /* Generic.Strong */ 40 | .highlight .gu { color: #f8f8f2; font-weight: bold } /* Generic.Subheading */ 41 | .highlight .gt { color: #f8f8f2 } /* Generic.Traceback */ 42 | .highlight .kc { color: #ff79c6 } /* Keyword.Constant */ 43 | .highlight .kd { color: #8be9fd; font-style: italic } /* Keyword.Declaration */ 44 | .highlight .kn { color: #ff79c6 } /* Keyword.Namespace */ 45 | .highlight .kp { color: #ff79c6 } /* Keyword.Pseudo */ 46 | .highlight .kr { color: #ff79c6 } /* Keyword.Reserved */ 47 | .highlight .kt { color: #8be9fd } /* Keyword.Type */ 48 | .highlight .ld { color: #f8f8f2 } /* Literal.Date */ 49 | .highlight .m { color: #bd93f9 } /* Literal.Number */ 50 | .highlight .s { color: #f1fa8c } /* Literal.String */ 51 | .highlight .na { color: #50fa7b } /* Name.Attribute */ 52 | .highlight .nb { color: #8be9fd; font-style: italic } /* Name.Builtin */ 53 | .highlight .nc { color: #50fa7b } /* Name.Class */ 54 | .highlight .no { color: #f8f8f2 } /* Name.Constant */ 55 | .highlight .nd { color: #f8f8f2 } /* Name.Decorator */ 56 | .highlight .ni { color: #f8f8f2 } /* Name.Entity */ 57 | .highlight .ne { color: #f8f8f2 } /* Name.Exception */ 58 | .highlight .nf { color: #50fa7b } /* Name.Function */ 59 | .highlight .nl { color: #8be9fd; font-style: italic } /* Name.Label */ 60 | .highlight .nn { color: #f8f8f2 } /* Name.Namespace */ 61 | .highlight .nx { color: #f8f8f2 } /* Name.Other */ 62 | .highlight .py { color: #f8f8f2 } /* Name.Property */ 63 | .highlight .nt { color: #ff79c6 } /* Name.Tag */ 64 | .highlight .nv { color: #8be9fd; font-style: italic } /* Name.Variable */ 65 | .highlight .ow { color: #ff79c6 } /* Operator.Word */ 66 | .highlight .w { color: #f8f8f2 } /* Text.Whitespace */ 67 | .highlight .mb { color: #bd93f9 } /* Literal.Number.Bin */ 68 | .highlight .mf { color: #bd93f9 } /* Literal.Number.Float */ 69 | .highlight .mh { color: #bd93f9 } /* Literal.Number.Hex */ 70 | .highlight .mi { color: #bd93f9 } /* Literal.Number.Integer */ 71 | .highlight .mo { color: #bd93f9 } /* Literal.Number.Oct */ 72 | .highlight .sa { color: #f1fa8c } /* Literal.String.Affix */ 73 | .highlight .sb { color: #f1fa8c } /* Literal.String.Backtick */ 74 | .highlight .sc { color: #f1fa8c } /* Literal.String.Char */ 75 | .highlight .dl { color: #f1fa8c } /* Literal.String.Delimiter */ 76 | .highlight .sd { color: #f1fa8c } /* Literal.String.Doc */ 77 | .highlight .s2 { color: #f1fa8c } /* Literal.String.Double */ 78 | .highlight .se { color: #f1fa8c } /* Literal.String.Escape */ 79 | .highlight .sh { color: #f1fa8c } /* Literal.String.Heredoc */ 80 | .highlight .si { color: #f1fa8c } /* Literal.String.Interpol */ 81 | .highlight .sx { color: #f1fa8c } /* Literal.String.Other */ 82 | .highlight .sr { color: #f1fa8c } /* Literal.String.Regex */ 83 | .highlight .s1 { color: #f1fa8c } /* Literal.String.Single */ 84 | .highlight .ss { color: #f1fa8c } /* Literal.String.Symbol */ 85 | .highlight .bp { color: #f8f8f2; font-style: italic } /* Name.Builtin.Pseudo */ 86 | .highlight .fm { color: #50fa7b } /* Name.Function.Magic */ 87 | .highlight .vc { color: #8be9fd; font-style: italic } /* Name.Variable.Class */ 88 | .highlight .vg { color: #8be9fd; font-style: italic } /* Name.Variable.Global */ 89 | .highlight .vi { color: #8be9fd; font-style: italic } /* Name.Variable.Instance */ 90 | .highlight .vm { color: #8be9fd; font-style: italic } /* Name.Variable.Magic */ 91 | .highlight .il { color: #bd93f9 } /* Literal.Number.Integer.Long */ 92 | -------------------------------------------------------------------------------- /source/_static/my_style.css: -------------------------------------------------------------------------------- 1 | .wy-nav-content { 2 | max-width: 1200px !important; 3 | } 4 | -------------------------------------------------------------------------------- /source/appendix-c/index.rst: -------------------------------------------------------------------------------- 1 | 附录 C:深入机器模式:RustSBI 2 | ================================================= 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 4 7 | 8 | RISC-V指令集的SBI标准规定了类Unix操作系统之下的运行环境规范。这个规范拥有多种实现,RustSBI是它的一种实现。 9 | 10 | RISC-V架构中,存在着定义于操作系统之下的运行环境。这个运行环境不仅将引导启动RISC-V下的操作系统, 还将常驻后台,为操作系统提供一系列二进制接口,以便其获取和操作硬件信息。 RISC-V给出了此类环境和二进制接口的规范,称为“操作系统二进制接口”,即“SBI”。 11 | 12 | SBI的实现是在M模式下运行的特定于平台的固件,它将管理S、U等特权上的程序或通用的操作系统。 13 | 14 | RustSBI项目发起于鹏城实验室的“rCore代码之夏-2020”活动,它是完全由Rust语言开发的SBI实现。 现在它能够在支持的RISC-V设备上运行rCore教程和其它操作系统内核。 15 | 16 | RustSBI项目的目标是,制作一个从固件启动的最小Rust语言SBI实现,为可能的复杂实现提供参考和支持。 RustSBI也可以作为一个库使用,帮助更多的SBI开发者适配自己的平台,以支持更多处理器核和片上系统。 17 | 18 | 当前项目实现源码:https://github.com/luojia65/rustsbi -------------------------------------------------------------------------------- /source/appendix-d/1asm.rst: -------------------------------------------------------------------------------- 1 | RISCV汇编相关 2 | ========================= 3 | 4 | - `RISC-V Assembly Programmer's Manual `_ 5 | - `RISC-V Low-level Test Suits `_ 6 | - `CoreMark®-PRO comprehensive, advanced processor benchmark `_ 7 | - `riscv-tests的使用 `_ -------------------------------------------------------------------------------- /source/appendix-d/2rv.rst: -------------------------------------------------------------------------------- 1 | RISCV硬件相关 2 | ========================= 3 | 4 | Quick Reference 5 | ------------------- 6 | - `Registers & ABI `_ 7 | - `Interrupt `_ 8 | - `ISA & Extensions `_ 9 | - `Toolchain `_ 10 | - `Control and Status Registers (CSRs) `_ 11 | - `Accessing CSRs `_ 12 | - `Assembler & Instructions `_ 13 | 14 | ISA 15 | ------------------------ 16 | 17 | - `User-Level ISA, Version 1.12 `_ 18 | - `4 Supervisor-Level ISA, Version 1.12 `_ 19 | - `Vector Extension `_ 20 | - `RISC-V Bitmanip Extension `_ 21 | - `External Debug `_ 22 | - `ISA Resources `_ -------------------------------------------------------------------------------- /source/appendix-d/index.rst: -------------------------------------------------------------------------------- 1 | 附录 D:RISC-V相关信息 2 | ================================================= 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 4 7 | 8 | 1asm 9 | 2rv -------------------------------------------------------------------------------- /source/chapter0/index.rst: -------------------------------------------------------------------------------- 1 | 第零章:实验环境搭建 2 | ============================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | 1setup-devel-env 8 | 9 | -------------------------------------------------------------------------------- /source/chapter1/0intro.rst: -------------------------------------------------------------------------------- 1 | 引言 2 | ===================== 3 | 4 | 本章导读 5 | -------------------------- 6 | 7 | .. 8 | 这是注释:我觉得需要给出执行环境(EE),Task,...等的描述。 9 | 并且有一个图,展示这些概念的关系。 10 | 11 | 本章展现了操作系统一个功能:让应用与硬件隔离,简化了应用访问硬件的难度和复杂性。 12 | 13 | 大多数程序员的第一行代码都从 ``Hello, world!`` 开始,当我们满怀着好奇心在编辑器内键入仅仅数个字节,再经过几行命令编译(靠的是编译器)、运行(靠的是操作系统),终于在黑洞洞的终端窗口中看到期望中的结果的时候,一扇通往编程世界的大门已经打开。在本章第一节 :doc:`1app-ee-platform` 中,可以看到用C语言编写的非常简单的“Hello, world”应用程序。 14 | 15 | 不过我们能够隐约意识到编程工作能够如此方便简洁并不是理所当然的,实际上有着多层硬件和软件工具和支撑环境隐藏在它背后,才让我们不必付出那么多努力就能够创造出功能强大的应用程序。生成应用程序二进制执行代码所依赖的是以 **编译器** 为主的开发环境;运行应用程序执行码所依赖的是以 **操作系统** 为主的执行环境。 16 | 17 | 本章我们将从操作系统最简单但也是最重要的println入手,要求大家实现一个裸机上的println以及带色彩的LOG,如info和warn,error等功能。因为大家是刚刚接触操作系统实验,本章的所有代码已经帮大家写好了,没有大家需要亲自编写代码的部分。但是它作为第一章又是最重要的一个章节:这一章之中,同学们要对整个 C 的 OS 实验框架有一个大致的掌握。对整个框架是如何编译的,之后需要写哪些内容以及如何测试有一个基本的认识。可以说,ch1 打好基础会使得之后的实验难度大大降低。 18 | 19 | 系统调用 20 | --------------------------- 21 | 22 | 在实验开始之前,大家要熟悉一下系统调用(syscall)的概念。相信大家在汇编的课程中一定接触过这个名词。我们 OS 课程中的 syscall 的意义也是一样的,它是操作系统提供给软件的一系列接口,使得软件能够使用系统的功能。syscall 本质上属于一种异常/中断,它在 riscv 的汇编指令中以 ecall 的形式出现。 23 | 24 | 本章的 println 所需要的在 console 中打印字符,也需要调用到 syscall。syscall 的种类有很多,操作系统通过区分 syscall 的 id 来判断是哪一个syscall。 25 | 26 | 实践体验 27 | --------------------------- 28 | 29 | 获取本章代码: 30 | 31 | .. code-block:: console 32 | 33 | $ git checkout ch1 34 | 35 | 在 qemu 模拟器上运行本章代码,看看一个小应用程序是如何在QEMU模拟的计算机上运行的: 36 | 37 | .. code-block:: console 38 | 39 | $ make run LOG=trace 40 | 41 | .. warning:: 42 | 43 | **FIXME: 提供 wsl/macOS 等更多平台支持** 44 | 45 | 如果顺利的话,以 qemu 平台为例,将输出: 46 | 47 | .. image:: ch1-demo.png 48 | 49 | 除了 ``Hello, world!`` 之外还有一些额外的信息,最后关机。 50 | 51 | .. note:: 52 | 53 | RustSBI是啥? 54 | 55 | 戳 :doc:`../appendix-c/index` 可以进一步了解RustSBI。 56 | 57 | 58 | 展望未来 59 | --------------------------- 60 | 61 | 现在我们的 os 会直接关机,在我们完成 lab5 之后,我们的 os 就可以比较自由的运行用户程序了。具体来说,我们会有一个 shell. 62 | 63 | .. image:: color-demo.png 64 | 65 | -------------------------------------------------------------------------------- /source/chapter1/1app-ee-platform.rst: -------------------------------------------------------------------------------- 1 | 代码框架简述 2 | ================================================ 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 5 7 | 8 | 9 | 本节导读 10 | ------------------------------- 11 | 12 | 本节会介绍代码的整体框架。我们默认大家都熟练掌握了C语言。 13 | 整个项目目前的代码树如下: 14 | 15 | .. code-block:: bash 16 | 17 | . 18 | ├── bootloader 19 | │   └── rustsbi-qemu.bin 20 | ├── LICENSE 21 | ├── Makefile 22 | ├── os 23 | │   ├── console.c 24 | │   ├── console.h 25 | │   ├── defs.h 26 | │   ├── entry.S 27 | │   ├── kernel.ld 28 | │   ├── log.h 29 | │   ├── main.c 30 | │   ├── printf.c 31 | │   ├── printf.h 32 | │   ├── riscv.h 33 | │   ├── sbi.c 34 | │   ├── sbi.h 35 | │   └── types.h 36 | ├── README.md 37 | 38 | OS是怎么跑起来的? 39 | ------------------------------- 40 | 41 | 我们的OS的运行,是要依赖著名的模拟器软件-qemu的。比较形象的比喻是,我们的os就是一个内核软件,qemu就类似一个主板,它模拟了许多硬件,比如CPU,I/O串口等等。我们的OS会和qemu模拟出来的这些硬件打交道,而qemu则把得到的指令分配给实际存在的硬件完成。 42 | 43 | 我们的OS启动的时候,就像一个真正的操作系统启动一样。qemu使用我们提供的rustsbi的bin文件做为引导程序来启动OS。同时,我们的内核做为运行在qemu中的虚拟机,是无法直接和我们的外部host系统通信的,因此我们OS自己实现的printf函数,想要真正地输出到我们外部运行的shell上被我们看到,是要经过qemu的。实际上,在启动时sbi已经帮我们初始化好了,经过qemu模拟出来的串口,最终打印到我们外部的shell上的。之后,从我们的shell之中读取输入,也是同样的道理。sbi为我们内核提供的功能不止于输入输出,在sbi.c文件的可以看到其他支持的功能,比如关机。 44 | 45 | .. note:: 46 | 47 | **RustSBI 是什么?** 48 | 49 | SBI 是 RISC-V 的一种底层规范,RustSBI 是它的一种实现。 操作系统内核与 RustSBI 的关系有点像应用与操作系统内核的关系,后者向前者提供一定的服务。只是SBI提供的服务很少, 比如关机,显示字符串,读入字符串等。 50 | 51 | qemu是怎么跑起来的? 52 | ------------------------------- 53 | 54 | qemu 拓展阅读: `qemu参数 `_ 。 55 | 56 | 第0章大家配置好了qemu之后可能就没再打开过了。qemu做为模拟器用途很多,操作也比较复杂。因此我们在makefile之中提供了具体运行qemu所需要的参数,大家无需更改。 57 | 58 | .. code-block:: makefile 59 | 60 | QEMU = qemu-system-riscv64 61 | QEMUOPTS = \ 62 | -nographic \ 63 | -smp $(CPUS) \ 64 | -machine virt \ 65 | -bios $(BOOTLOADER) \ 66 | -kernel kernel 67 | 68 | run: $(BUILDDIR)/kernel 69 | $(QEMU) $(QEMUOPTS) 70 | 71 | 这个就是最关键的地方:make run。我们查看这条指令的结构,它首先执行上面 kernel 所需要的链接以及编译操作得到一个二进制的kernel。之后执行按照QEMUOPTS变量指定的参数启动qemu。QEMUOPTS意义如下: 72 | 73 | - nographic: 无图形界面 74 | - smp 1: 单核 (默认值,可以省略) 75 | - machine virt: 模拟硬件 RISC-V VirtIO Board 76 | - bios $(bios): 使用制定 bios,这里指向的是我们提供的 rustsbi 的bin文件。 77 | - kernel: 使用 elf 格式的 kernel。这里就是我们需要写的OS内核了。 78 | 79 | make run这个指令,应该会陪伴大家走过接下来所有的实验qaq。它完成了内核代码的编译生成kernel,并按照QEMUOPTS变量指定的参数加载我们的kernel,“加电”启动qemu。 此时,CPU 的其它通用寄存器清零,而 PC 会指向 0x1000 的位置,这里有固化在硬件中的一小段引导代码,它会很快跳转到 0x80000000 的 RustSBI 处。 RustSBI完成硬件初始化后,会跳转到 $(KERNEL_BIN) 所在内存位置 0x80200000 处, 执行我们操作系统的第一条指令。 80 | 81 | .. image:: chap1-intro.png 82 | :align: center 83 | :name: function-call 84 | 85 | 那么,知道了这些步骤之后,关键就是怎么去写我们的OS了,这也是我们接下来各个实验的内容~。我们OS的代码,基本全部在os文件夹下。nfs文件夹下有一些文件系统相关的内容,在第七章之前大家无需关注这个文件夹下的内容。 86 | 87 | os文件夹 88 | ------------------------------- 89 | 90 | os文件夹下存放了所有我们构建操作系统的源代码,是本次实验中最最重要的一部分,也是整个实验过程中同学们唯一需要修改的部分。在开始实验之前,大家一定要清楚我们这是自己设计的 OS,是无法使用C提供的官方标准库的,也就是说,就算是最简单的 printf 之类的函数都无法使用。还好,作为一个轻量级的 OS,我们也用不到那么多函数。 91 | 92 | 我们的os是一个由makefile来构建的C项目。下面介绍框架之中一些重要文件的作用,以及整个项目是如何链接及编译的。 93 | 94 | - kernel.ld 95 | kernel.ld是我们用于链接项目的脚本。链接脚本决定了 elf 程序的内存空间布局(严格的讲是虚存映射,注意程序中的各种绝对地址就在链接的时候确定),由于刚进入 S 态的时候我们尚未激活虚存机制,我们必须把 os 置于物理内存的 0x80200000 处(这个地址的来由请参考 rustsbi) 96 | 97 | .. code-block:: ld 98 | 99 | 100 | BASE_ADDRESS = 0x80200000; 101 | SECTIONS 102 | { 103 | . = BASE_ADDRESS; 104 | skernel = .; 105 | 106 | stext = .; 107 | .text : { 108 | *(.text.entry) # 第一行代码 109 | *(.text .text.*) 110 | } 111 | 112 | ... 113 | } 114 | 115 | SECTIONS 之中是从 BASE_ADDRESS 开始的各段。对程序内存布局还不太熟悉的同学可以翻看后面内存布局的章节。以 text 段为例,它是由不同文件的 text 组成。我们没有规定这些 text 段的具体顺序,但是我们规定了一个特殊的 text 段:.text.entry 段,该 text 段是 BASE_ADDRESS 后的第一个段,该段的第一行代码就在 0x80200000 处。这个特殊的段不是编译生成的,它在 entry.S 中人为设定。 116 | 117 | - entry.S 118 | .. code-block:: asm 119 | 120 | # entry.S 121 | .section .text.entry 122 | .globl _entry 123 | _entry: 124 | la sp, boot_stack 125 | call main 126 | 127 | .section .bss.stack 128 | .globl boot_stack 129 | boot_stack: 130 | .space 4096 * 16 131 | .globl boot_stack_top 132 | boot_stack_top: 133 | 134 | .text.entry 段中只有一个函数 _entry,它干的事情也十分简单,设置好 os 运行的堆栈(la sp, boot_stack语句。bootloader 并没有好心的设置好这些),然后调用 main 函数。main 函数位于 main.c 中,从此开始我们就基本进入了 C 的世界。 135 | 136 | - main.c 137 | 它是os的入口函数。在其中我们会完成一系列的初始化并开始运行os。 138 | 作为第一章,它在初始化完毕之后实际上起到了一个测试的作用。如果你的main.c能够完成一系列打印并且最后成功退出(Shutdown),那么祝贺你,你完成了os的第一步。 139 | 140 | .. code-block:: c 141 | 142 | extern char s_text[]; 143 | extern char e_text[]; 144 | // ... 145 | 146 | void main() 147 | { 148 | clean_bss(); 149 | console_init(); 150 | printf("\n"); 151 | printf("hello wrold!\n"); 152 | errorf("stext: %p", s_text); 153 | // ... 154 | errorf("ebss : %p", e_bss); 155 | panic("ALL DONE"); 156 | } 157 | 158 | 其中,main.c 之中众多的 extern 声明的内存段是在 ld 文件之中定义的,通过这些 symbol 我们可以大致了解 OS 的内存布局。 159 | 160 | 此外 ``clean_bss()`` 清空了 ``bss`` 段,注意,清空 elf 程序 .bss 段这一工作通常是由 OS 做的,而我们就只好自立更生了。 161 | 162 | 你可能注意到除了 printf 之外,还有一些用于 log 的彩色输出宏。感兴趣的同学可以看看 log.h 。 163 | 164 | - sbi.c 165 | printf 的实现在 printf.c,在函数之中我们完成了对 format 字符串的解析工作。那么我们是如何把字符串真正地打印到 shell 上的呢? 我们 调用consputc 函数输出一个 char 到 shell,而 consputc 函数其实就是调用了 sbi.c 之中的 console_putchar 函数。这个 console_putchar 函数的本质是调用了 sbi_call。剥开层层套娃,大家可以发现打印的最终实现是使用 sbi 帮助我们包装好的 ecall 汇编代码,通过指定 ecall 的 idx 为 SBI_CONSOLE_PUTCHAR, 并将我们的字符做为参数传入到 ecall 指定的寄存器之中完成一次系统调用来实现的。 166 | 本来,作为一个 OS,串口输出(也就是输出到 shell)的事情也应该我们自己来做,但这里为了简化这些硬件强相关的实现,我们利用 rust-sbi 的 M 态支持。这也是 riscv 灵活性的一个体现。 167 | 168 | rustsbi 拓展阅读:`rsutsbi `_ 。 169 | 170 | bootloader文件夹 171 | ------------------------------- 172 | 173 | 这个文件夹是用来存放 bootloader(也就是 rustsbi) 的 bin 文件的,这一章以及之后都无需我们做任何修改。 174 | 175 | 硬件加电之后是处于M态,而 rustsbi 帮助我们完成了 M 态的初始化,最终将 PC 移动至我们 os 开始执行的位置。同时,它也会帮助S态的 os 完成一些基本管理,详情可以看 os/sbi.c 文件。 176 | 177 | 178 | -------------------------------------------------------------------------------- /source/chapter1/2remove-std.rst: -------------------------------------------------------------------------------- 1 | .. _term-remove-std: 2 | 3 | makefile 和 qemu 4 | ========================== 5 | 6 | .. toctree:: 7 | :hidden: 8 | :maxdepth: 5 9 | 10 | 本节导读 11 | ------------------------------- 12 | 13 | 为了帮助大家进一步理解我们的项目的链接和编译的过程,这里简要介绍一下 makefile 的内容。 14 | 15 | .. warning:: 16 | 17 | 注意,makefile 在整个实验过程中不可修改,否则可能导致 CI 无法通过! 18 | 19 | 20 | makefile 内部 21 | ---------------------------------- 22 | 23 | 指定编译使用的工具 24 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 25 | 26 | .. code-block:: makefile 27 | 28 | TOOLPREFIX = riscv64-unknown-elf- 29 | CC = $(TOOLPREFIX)gcc 30 | AS = $(TOOLPREFIX)gas 31 | LD = $(TOOLPREFIX)ld 32 | OBJCOPY = $(TOOLPREFIX)objcopy 33 | OBJDUMP = $(TOOLPREFIX)objdump 34 | GDB = $(TOOLPREFIX)gdb 35 | 36 | 这里makefile调用了大家设定好的PATH之中的riscv64工具链。如果没有设置好,那么之后的编译就会因为找不到这些文件而出错。 37 | 38 | 添加编译flag 39 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 40 | 41 | .. code-block:: makefile 42 | 43 | CFLAGS = -Wall -Werror -O -fno-omit-frame-pointer -ggdb 44 | CFLAGS += -MD 45 | CFLAGS += -mcmodel=medany 46 | CFLAGS += -ffreestanding -fno-common -nostdlib -mno-relax 47 | CFLAGS += -I. 48 | CFLAGS += $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector) 49 | 50 | 比较需要注意的是我们设置了警告也会报错,因此大家写代码的时候最好避免 warning 的出现,这是良好的编程习惯。 51 | 52 | 设置编译目标 53 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 54 | 55 | .. code-block:: makefile 56 | 57 | # 目录定义 58 | K = os 59 | BUILDDIR = build 60 | # .o 目标的确定,也就是 os 目录下所有的 .c 和 .s 都编译成 .o 61 | C_SRCS = $(wildcard $K/*.c) 62 | AS_SRCS = $(wildcard $K/*.S) 63 | C_OBJS = $(addprefix $(BUILDDIR)/, $(addsuffix .o, $(basename $(C_SRCS)))) 64 | AS_OBJS = $(addprefix $(BUILDDIR)/, $(addsuffix .o, $(basename $(AS_SRCS)))) 65 | OBJS = $(C_OBJS) $(AS_OBJS) 66 | # kernel 镜像由所有的 .o 按照 kernel.ld 链接而成 67 | $(BUILDDIR)/kernel: $(OBJS) $(K)/kernel.ld 68 | $(LD) $(LDFLAGS) -T kernel.ld -o kernel $(OBJS) 69 | 70 | 请同学们自行查阅并了解``wildcard``、``addprefix``、``addsuffix``、``basename``的意义。 71 | 72 | 运行 qemu 73 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 74 | 75 | .. code-block:: makefile 76 | 77 | QEMU = qemu-system-riscv64 78 | QEMUOPTS = \ 79 | -nographic \ 80 | -smp $(CPUS) \ 81 | -machine virt \ 82 | -bios $(BOOTLOADER) \ 83 | -kernel kernel 84 | 85 | run: $(BUILDDIR)/kernel 86 | $(QEMU) $(QEMUOPTS) 87 | 88 | 这里和前面一致。大家不需要太关心qemu的更多细节,我们涉及它的操作已经在makefile和sbi之中处理了。 89 | 90 | gdb 调试 91 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 92 | 93 | .. code-block:: makefile 94 | 95 | # QEMU's gdb stub command line changed in 0.11 96 | QEMUGDB = $(shell if $(QEMU) -help | grep -q '^-gdb'; \ 97 | then echo "-gdb tcp::1234"; \ 98 | else echo "-s -p 1234"; fi) 99 | 100 | debug: kernel .gdbinit 101 | $(QEMU) $(QEMUOPTS) -S $(QEMUGDB) & 102 | sleep 1 103 | $(GDB) 104 | 105 | 使用 make debug 来使用 gdb 调试 qemu。程序自身执行的机制和直接 make run 一样。在解析 bootloader 的行为时可以使用 gdb 在其中添加断点来查看对应寄存器和内存的内容。gdb的具体使用方法和汇编课程上一致。不熟悉的同学可以在训练章节查看到可能用到的gdb指令的简单用法,也十分推荐同学们自学一些基础的 gdb 使用方法,掌握 gdb 对本课程帮助很大。 106 | 107 | LOG 支持 108 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 109 | .. code-block:: makefile 110 | 111 | ifeq ($(LOG), error) 112 | CFLAGS += -D LOG_LEVEL_ERROR 113 | else ifeq ($(LOG), warn) 114 | CFLAGS += -D LOG_LEVEL_WARN 115 | else ifeq ($(LOG), info) 116 | CFLAGS += -D LOG_LEVEL_INFO 117 | else ifeq ($(LOG), debug) 118 | CFLAGS += -D LOG_LEVEL_DEBUG 119 | else ifeq ($(LOG), trace) 120 | CFLAGS += -D LOG_LEVEL_TRACE 121 | endif 122 | 123 | 我们的 log 等级选择是通过 -D 参数来实现的,这也是大家 ``make run LOG=xxx`` 的原理。从这里我们也可以看到 ``LOG`` 的可选值。 124 | 125 | .. warngin:: 126 | 127 | FIX ME: 128 | 大家在实际使用中会发现,由于 LOG 是静态编译是就确认的参数,所以如果想要改变 LOG 等级,就需要重新编译几乎所有的源文件。目前在需要改变 LOG 等级的时候需要 make clean 然后重新 make run。 -------------------------------------------------------------------------------- /source/chapter1/3exercise.rst: -------------------------------------------------------------------------------- 1 | chapter1练习(已废弃) 2 | ===================================================== 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 4 7 | 8 | - 本节难度: **低** 9 | 10 | 本节任务 11 | ------------------------------- 12 | - 注意 ch1 并不对应一次 lab 提交,本节任务在 ch3 最终提交。 13 | - 运行 ch1 分支的代码。 14 | - 结合实验指导书,掌握代码的基本结构。 15 | - 自学 ``.ld`` ``makefile`` 两个格式文件的基本使用方法。能够基本读懂 os/kernel.ld 和 Makefile。 16 | - 运行 ``make debug``, 自学 gdb 调试的方法,完成问答作业(lab1 报告要求)。 17 | 18 | 编程作业 19 | ------------------------------- 20 | 无 21 | 22 | .. ch1问答作业:: 23 | 24 | 问答作业 25 | ------------------------------- 26 | 27 | 无 28 | -------------------------------------------------------------------------------- /source/chapter1/CallStack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter1/CallStack.png -------------------------------------------------------------------------------- /source/chapter1/MemoryLayout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter1/MemoryLayout.png -------------------------------------------------------------------------------- /source/chapter1/StackFrame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter1/StackFrame.png -------------------------------------------------------------------------------- /source/chapter1/app-software-stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter1/app-software-stack.png -------------------------------------------------------------------------------- /source/chapter1/ch1-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter1/ch1-demo.png -------------------------------------------------------------------------------- /source/chapter1/chap1-intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter1/chap1-intro.png -------------------------------------------------------------------------------- /source/chapter1/color-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter1/color-demo.png -------------------------------------------------------------------------------- /source/chapter1/function-call.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter1/function-call.png -------------------------------------------------------------------------------- /source/chapter1/index.rst: -------------------------------------------------------------------------------- 1 | .. _link-chapter1: 2 | 3 | 第一章:应用程序与基本执行环境 4 | ============================================== 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | 0intro 10 | 1app-ee-platform 11 | 2remove-std 12 | 13 | 14 | -------------------------------------------------------------------------------- /source/chapter2/0intro.rst: -------------------------------------------------------------------------------- 1 | 引言 2 | ================================ 3 | 4 | 本章导读 5 | --------------------------------- 6 | 7 | .. 8 | chyyuu:有一个ascii图,画出我们做的OS。 9 | 10 | 本章展现了操作系统一系列功能: 11 | 12 | - 通过批处理支持多个程序的自动加载和运行 13 | - 操作系统利用硬件特权级机制,实现对操作系统自身的保护 14 | 15 | 上一章,我们在 RV64 裸机平台上成功运行起来了 ``Hello, world!`` 并成功实现了染色的过程 。看起来这个过程非常顺利,只需要一条命令就能全部完成。但实际上,在那个计算机刚刚诞生的年代,很多事情并不像我们想象的那么简单。当时,程序被记录在打孔的卡片上,使用汇编语言甚至机器语言来编写。而稀缺且昂贵的计算机由专业的管理员负责操作,就和我们在上一章所做的事情一样,他们手动将卡片输入计算机,等待程序运行结束或者终止程序的运行。最后,他们从计算机的输出端——也就是打印机中取出程序的输出并交给正在休息室等待的程序提交者。 16 | 17 | 实际上,这样做是一种对于珍贵的计算资源的浪费。因为当时的计算机和今天的个人计算机不同,它的体积极其庞大,能够占满一整个空调房间,像巨大的史前生物。管理员在房间的各个地方跑来跑去、或是等待打印机的输出的这些时间段,计算机都并没有在工作。于是,人们希望计算机能够不间断的工作且专注于计算任务本身。 18 | 19 | .. _term-batch-system: 20 | 21 | **批处理系统** (Batch System) 应运而生。它的核心思想是:将多个程序打包到一起输入计算机。而当一个程序运行结束后,计算机会 *自动* 加载下一个程序到内存并开始执行。这便是最早的真正意义上的操作系统。 22 | 23 | .. _term-privilege: 24 | 25 | 程序总是难免出现错误。但人们希望一个程序的错误不要影响到操作系统本身,它只需要终止出错的程序,转而运行执行序列中的下一个程序即可。如果后面的程序都无法运行就太糟糕了。这种 *保护* 操作系统不受有意或无意出错的程序破坏的机制被称为 **特权级** (Privilege) 机制,它实现了用户态和内核态的隔离,需要软件和硬件的共同努力。 26 | 27 | 本章我们的主要目的也是设计一个批处理的操作系统。毕竟将待执行的程序嵌入main.c之中是十分粗暴的,也不符合我们对操作系统的认知。这同时也意味着我们将开始使用独立的测例文件,并把它们打包到os之中。 28 | 29 | .. image:: deng-fish.png 30 | :align: center 31 | :name: fish-os 32 | 33 | 实践体验 34 | --------------------------- 35 | 36 | 本章我们引入了用户程序,为了解耦内核与用户程序,我们分离了两个仓库,分别是存放内核程序的 ``uCore-Tutorial-Code-20xxx`` (下称代码仓库,最后几位 x 表示学期)与存放用户程序的 ``uCore-Tutorial-Test-20xxx`` (下称测例仓库)。 因此首先你需要进入代码仓库文件夹(如果已经执行过该步骤则不需要再重复执行)并 clone 用户程序仓库: 37 | 38 | .. code-block:: console 39 | 40 | $ cd uCore-Tutorial-Code-2022S 41 | $ git clone https://github.com/LearningOS/uCore-Tutorial-Test-2022S.git user 42 | 43 | 上面的指令会将测例仓库克隆到代码仓库下并命名为 ``user`` ,注意 ``/user`` 在代码仓库的 ``.gitignore`` 中,因此不会出现 ``.git`` 文件夹嵌套的问题,并且你 ``checkout`` 代码仓库时也不会影响测例仓库的内容。 44 | 45 | .. note:: 46 | 47 | 如果测例仓库有所更新或者你切换了代码仓库的分支,你可能需要清理掉测例仓库原版的编译结果,此时需要执行 48 | 49 | .. code-block:: console 50 | 51 | $ make -C user clean 52 | 53 | 它的作用基本等价于如下写法,但是更简便 54 | 55 | .. code-block:: console 56 | 57 | $ cd user 58 | $ make clean 59 | $ cd .. 60 | 61 | 我们可以通过 ``make user`` 生成用户程序,最终将 ``.bin`` 文件放在 ``user/target/bin`` 目录下。 62 | 63 | .. code-block:: console 64 | 65 | $ git checkout ch2 66 | 67 | .. code-block:: console 68 | 69 | $ make user BASE=1 CHAPTER=2 70 | $ make run 71 | 72 | 也可以直接运行打包好的测试程序。make test 会完成 make user 和 make run 两个步骤(自动设置 CHAPTER),我们可以通过 BASE 控制是否生成留做练习的测例。 73 | 74 | .. code-block:: console 75 | 76 | $ make test BASE=1 77 | 78 | 79 | 如果顺利的话,我们可以看到批处理系统自动加载并运行所有的程序并且正确在程序出错的情况下保护了自身: 80 | 81 | .. code-block:: bash 82 | 83 | .______ __ __ _______.___________. _______..______ __ 84 | | _ \ | | | | / | | / || _ \ | | 85 | | |_) | | | | | | (----`---| |----`| (----`| |_) || | 86 | | / | | | | \ \ | | \ \ | _ < | | 87 | | |\ \----.| `--' |.----) | | | .----) | | |_) || | 88 | | _| `._____| \______/ |_______/ |__| |_______/ |______/ |__| 89 | 90 | [rustsbi] Platform: QEMU (Version 0.1.0) 91 | [rustsbi] misa: RV64ACDFIMSU 92 | [rustsbi] mideleg: 0x222 93 | [rustsbi] medeleg: 0xb1ab 94 | [rustsbi-dtb] Hart count: cluster0 with 1 cores 95 | [rustsbi] Kernel entry: 0x80200000 96 | hello wrold! 97 | Hello world from user mode program! 98 | Test hello_world OK! 99 | 3^10000=5079 100 | 3^20000=8202 101 | 3^30000=8824 102 | 3^40000=5750 103 | 3^50000=3824 104 | 3^60000=8516 105 | 3^70000=2510 106 | 3^80000=9379 107 | 3^90000=2621 108 | 3^100000=2749 109 | Test power OK! 110 | string from data section 111 | strinstring from stack section 112 | strin 113 | Test write1 OK! 114 | ALL DONE 115 | 116 | 可以看到 4 个基础测试程序都可以正常运行。 117 | 118 | 本章代码导读 119 | ----------------------------------------------------- 120 | 121 | 相比于上一章的操作系统,本章操作系统有两个最大的不同之处,一个是支持应用程序在用户态运行,且能完成应用程序发出的系统调用;另一个是能够一个接一个地自动运行不同的应用程序。所以,我们需要对操作系统和应用程序进行修改,也需要对应用程序的编译生成过程进行修改。 122 | 123 | 首先改进应用程序,让它能够在用户态执行,并能发出系统调用。这其实就是上一章中 :ref:`构建用户态执行环境 ` 小节介绍内容的进一步改进。具体而言,编写多个应用小程序,修改编译应用所需的 ``linker.ld`` 文件来 :ref:`调整程序的内存布局 ` ,让操作系统能够把应用加载到指定内存地址后顺利启动并运行应用程序。 124 | 125 | 应用程序运行中,操作系统要支持应用程序的输出功能,并还能支持应用程序退出。这需要完成 ``sys_write`` 和 ``sys_exit`` 系统调用访问请求的实现。 具体实现涉及到内联汇编的编写,以及应用与操作系统内核之间系统调用的参数传递的约定。为了让应用在还没实现操作系统之前就能进行运行测试,我们采用了Linux on RISC-V64 的系统调用参数约定。具体实现可参看 :ref:`系统调用 ` 小节中的内容。 这样写完应用小例子后,就可以通过 ``qemu-riscv64`` 模拟器进行测试了。 126 | 127 | 写完应用程序后,还需实现支持多个应用程序轮流启动运行的操作系统。这里首先能把本来相对松散的应用程序执行代码和操作系统执行代码连接在一起,便于 ``qemu-system-riscv64`` 模拟器一次性地加载二者到内存中,并让操作系统能够找到应用程序的位置。为把二者连在一起,需要对生成的应用程序进行改造,首先是把应用程序执行文件从ELF执行文件格式变成Binary格式(通过 ``rust-objcopy`` 可以轻松完成);然后这些Binary格式的文件通过编译器辅助脚本 ``scripts/pack.py`` 生成 ``os/link_app.S`` 这个汇编文件,并生成各个Binary应用的辅助信息,便于操作系统能够找到应用的位置。同时,makefile也会调用另外一个脚本``scripts/kernellld.py``来生一个新的规定程序空间的kernel_app.ld取代之前的kernel.ld。编译器会把把操作系统的源码和 ``os/link_app.S`` 合在一起,编译出操作系统+Binary应用的ELF执行文件,并进一步转变成Binary格式。 128 | 129 | 操作系统本身需要完成对Binary应用的位置查找,找到后(通过 ``os/link_app.S`` 中的变量和标号信息完成),会把Binary应用拷贝到 ``os/kernel_app.ld`` 指定的物理内存位置(OS的加载应用功能)。 130 | 131 | 更加详细的内容,主要在 :ref:`实现批处理操作系统 ` 小节中讲解。 132 | 133 | 为了让Binary应用能够启动和运行,操作系统还需给Binary应用分配好执行环境所需一系列的资源。这主要包括设置好用户栈和内核栈(在应用在用户态和内核在内核态需要有各自的栈),实现Trap 上下文的保存与恢复(让应用能够在发出系统调用到内核态后,还能回到用户态继续执行),完成Trap 分发与处理等工作。由于涉及用户态与内核态之间的特权级切换细节的汇编代码,与硬件细节联系紧密,所以 :ref:`这部分内容 ` 是本章中理解比较困难的地方。如果要了解清楚,需要对涉及到的CSR寄存器的功能有清楚的认识。这就需要看看 `RISC-V手册 `_ 的第十章或更加详细的RISC-V的特权级规范文档了。有了上面的实现后,就剩下最后一步,实现 **执行应用程序** 的操作系统功能,其主要实现在 ``run_next_app`` 函数中 。 134 | -------------------------------------------------------------------------------- /source/chapter2/1rv-privilege.rst: -------------------------------------------------------------------------------- 1 | 特权级机制 2 | ===================================== 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 5 7 | 8 | 本节导读 9 | ------------------------------- 10 | 11 | 为了保护我们的批处理操作系统不受到出错应用程序的影响并全程稳定工作,单凭软件实现是很难做到的,而是需要 CPU 提供一种特权级隔离机制,使CPU在执行应用程序和操作系统内核的指令时处于不同的特权级。本节主要介绍了特权级机制的软硬件设计思路,以及RISC-V的特权级架构,包括特权指令的描述。 12 | 13 | 特权级的软硬件协同设计 14 | ------------------------------------------ 15 | 16 | 实现特权级机制的根本原因是应用程序运行的安全性不可充分信任。在上一节里,操作系统和应用紧密连接在一起,形成一个应用程序来执行。随着应用需求的增加,操作系统也越来越大,会以库的形式存在;同时应用自身也会越来越复杂。由于操作系统会给多个应用提供服务,所以它可能的错误会比较快地被发现,但应用自身的错误可能就不会很快发现。由于二者通过编译器形成一个应用程序来执行,即使是应用本身的问题,也会导致操作系统受到连累,从而可能导致整个计算机系统都不可用了。 17 | 所以,计算机专家就想到一个方法,能否让相对安全可靠的操作系统不受到应用程序的破坏,运行在一个安全的执行环境中,而让应用程序运行在一个无法破坏操作系统的执行环境中? 18 | 19 | 为确保操作系统的安全,对应用程序而言,需要限制的主要有两个方面: 20 | - 应用程序不能访问任意的地址空间(这个在第四章会进一步讲解,本章不会讲解) 21 | - 应用程序不能执行某些可能破会计算机系统的指令(本章的重点) 22 | 23 | 假设有了这样的限制,我们还需要确保应用程序能够得到操作系统的服务,即应用程序和操作系统还需要有交互的手段。使得低特权级软件都只能做高特权级软件允许它做的,且低特权级软件的超出其能力的要求必须寻求高特权级软件的帮助。在这里的高特权级软件就是低特权级软件的软件执行环境。 24 | 25 | 为了完成这样的特权级需求,需要进行软硬件协同设计。一个比较简洁的方法就是,处理器设置两个不同安全等级的执行环境:用户态特权级的执行环境和内核态特权级的执行环境。且明确指出可能破会计算机系统的内核态特权级指令子集,规定内核态特权级指令子集中的指令只能在内核态特权级的执行环境中执行,如果在用户态特权级的执行环境中执行这些指令,会产生异常。处理器在执行不同特权级的执行环境下的指令前进行特权级安全检查。 26 | 27 | 为了让应用程序获得操作系统的函数服务,采用传统的函数调用方式(即通常的 ``call`` 和 ``ret`` 指令或指令组合)将会直接绕过硬件的特权级保护检查。所以要设计新的指令:执行环境调用(Execution Environment Call,简称 ``ecall`` )和执行环境返回(Execution Environment Return,简称 ``eret`` )): 28 | 29 | - ``ecall`` :具有用户态到内核态的执行环境切换能力的函数调用指令(RISC-V中就有这条指令) 30 | - ``eret`` :具有内核态到用户态的执行环境切换能力的函数返回指令(RISC-V中有类似的 ``sret`` 指令) 31 | 32 | 但硬件具有了这样的机制后,还需要操作系统的配合才能最终完成对操作系统自己的保护。首先,操作系统需要提供相应的控制流,能在执行 ``eret`` 前准备和恢复用户态执行应用程序的上下文。其次,在应用程序调用 ``ecall`` 指令后,能够保存用户态执行应用程序的上下文,便于后续的恢复;且还要坚持应用程序发出的服务请求是安全的。 33 | 34 | .. note:: 35 | 36 | 在实际的CPU,如x86、RISC-V等,设计了多达4种特权级。对于一般的操作系统而言,其实只要两种特权级就够了。 37 | 38 | 39 | RISC-V 特权级架构 40 | ------------------------------------------ 41 | 42 | RISC-V 架构中一共定义了 4 种特权级: 43 | 44 | .. list-table:: RISC-V 特权级 45 | :widths: 30 30 60 46 | :header-rows: 1 47 | :align: center 48 | 49 | * - 级别 50 | - 编码 51 | - 名称 52 | * - 0 53 | - 00 54 | - 用户/应用模式 (U, User/Application) 55 | * - 1 56 | - 01 57 | - 监督模式 (S, Supervisor) 58 | * - 2 59 | - 10 60 | - H, Hypervisor 61 | * - 3 62 | - 11 63 | - 机器模式 (M, Machine) 64 | 65 | 其中,级别的数值越大,特权级越高,掌控硬件的能力越强。从表中可以看出, M 模式处在最高的特权级,而 U 模式处于最低的特权级。 66 | 67 | 之前我们给出过支持应用程序运行的一套 :ref:`执行环境栈 ` ,现在我们站在特权级架构的角度去重新看待它: 68 | 69 | .. image:: PrivilegeStack.png 70 | :align: center 71 | :name: PrivilegeStack 72 | 73 | .. _term-see: 74 | 75 | 和之前一样,白色块表示一层执行环境,黑色块表示相邻两层执行环境之间的接口。这张图片给出了能够支持运行 Unix 这类复杂系统的软件栈。其中 76 | 内核代码运行在 S 模式上;应用程序运行在 U 模式上。运行在 M 模式上的软件被称为 **监督模式执行环境** (SEE, Supervisor Execution Environment) 77 | ,这是站在运行在 S 模式上的软件的视角来看,它的下面也需要一层执行环境支撑,因此被命名为 SEE,它需要在相比 S 模式更高的特权级下运行, 78 | 一般情况下在 M 模式上运行。 79 | 80 | .. note:: 81 | 82 | **按需实现 RISC-V 特权级** 83 | 84 | RISC-V 架构中,只有 M 模式是必须实现的,剩下的特权级则可以根据跑在 CPU 上应用的实际需求进行调整: 85 | 86 | - 简单的嵌入式应用只需要实现 M 模式; 87 | - 带有一定保护能力的嵌入式系统需要实现 M/U 模式; 88 | - 复杂的多任务系统则需要实现 M/S/U 模式。 89 | - 到目前为止,(Hypervisor, H)模式的特权规范还没完全制定好。所以本书不会涉及。 90 | 91 | 92 | 之前我们提到过,执行环境的其中一种功能是在执行它支持的上层软件之前进行一些初始化工作。我们之前提到的引导加载程序会在加电后对整个系统进行 93 | 初始化,它实际上是 SEE 功能的一部分,也就是说在 RISC-V 架构上引导加载程序一般运行在 M 模式上。此外,编程语言的标准库也会在执行程序员 94 | 编写的逻辑之前进行一些初始化工作,但是在这张图中我们并没有将其展开,而是统一归类到 U 模式软件,也就是应用程序中。 95 | 96 | 回顾第一章,当时只是实现了简单的支持单个裸机应用的库级别的“三叶虫”操作系统,它和应用程序全程运行在 S 模式下,应用程序很容易破坏没有任何保护的执行环境--操作系统。而在后续的章节中,我们会涉及到RISC-V的 M/S/U 三种特权级:其中应用程序和用户态支持库运行在 U 模式的最低特权级;操作系统内核运行在 S 模式特权级(在本章表现为一个简单的批处理系统),形成支撑应用程序和用户态支持库的执行环境;而第一章提到的预编译的 bootloader -- ``RustSBI`` 实际上是运行在更底层的 M 模式特权级下的软件,是操作系统内核的执行环境。整个软件系统就由这三层运行在不同特权级下的不同软件组成。 97 | 98 | 在特权级相关机制方面,本书正文中我们重点关心RISC-V的 S/U 特权级, M 特权级的机制细节则是作为可选内容在 :doc:`/appendix-c/index` 中讲解,有兴趣的读者可以参考。 99 | 100 | .. _term-ecf: 101 | .. _term-trap: 102 | 103 | 执行环境的另一种功能是对上层软件的执行进行监控管理。监控管理可以理解为,当上层软件执行的时候出现了一些情况导致需要用到执行环境中提供的功能, 104 | 因此需要暂停上层软件的执行,转而运行执行环境的代码。由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往(而 **不一定** ) 105 | 伴随着 CPU 的 **特权级切换** 。当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行。在 RISC-V 架构中,这种与常规控制流 106 | (顺序、循环、分支、函数调用)不同的 **异常控制流** (ECF, Exception Control Flow) 被称为 **异常(Exception)** 。 107 | 108 | .. _term-exception: 109 | 110 | 用户态应用直接触发从用户态到内核态的 **异常控制流** 的原因总体上可以分为两种:执行 ``Trap类异常`` 指令和执行了会产生 ``Fault类异常`` 的指令 。``Trap类异常`` 指令 111 | 就是指用户态软件为获得内核态操作系统的服务功能而发出的特殊指令。 ``Fault类`` 的指令是指用户态软件执行了在内核态操作系统看来是非法操作的指令。下表中我们给出了 RISC-V 特权级定义的会导致从低特权级到高特权级的各种 **异常**: 112 | 113 | .. list-table:: RISC-V 异常一览表 114 | :align: center 115 | :header-rows: 1 116 | :widths: 30 30 60 117 | 118 | * - Interrupt 119 | - Exception Code 120 | - Description 121 | * - 0 122 | - 0 123 | - Instruction address misaligned 124 | * - 0 125 | - 1 126 | - Instruction access fault 127 | * - 0 128 | - 2 129 | - Illegal instruction 130 | * - 0 131 | - 3 132 | - Breakpoint 133 | * - 0 134 | - 4 135 | - Load address misaligned 136 | * - 0 137 | - 5 138 | - Load access fault 139 | * - 0 140 | - 6 141 | - Store/AMO address misaligned 142 | * - 0 143 | - 7 144 | - Store/AMO access fault 145 | * - 0 146 | - 8 147 | - Environment call from U-mode 148 | * - 0 149 | - 9 150 | - Environment call from S-mode 151 | * - 0 152 | - 11 153 | - Environment call from M-mode 154 | * - 0 155 | - 12 156 | - Instruction page fault 157 | * - 0 158 | - 13 159 | - Load page fault 160 | * - 0 161 | - 15 162 | - Store/AMO page fault 163 | 164 | .. _term-environment-call: 165 | 166 | 其中断点(Breakpoint) 和 **执行环境调用** (Environment call) 两个异常(为了与其他非有意为之的异常区分,会把这种有意为之的指令称为 ``陷入`` 或 167 | ``trap`` 类指令)是通过在上层软件中执行一条特定的指令触发的:当执行 ``ebreak`` 168 | 这条指令的之后就会触发断点陷入异常;而执行 ``ecall`` 这条指令的时候则会随着 CPU 当前所处特权级而触发不同的 ``陷入`` 情况。从表中可以看出,当 CPU 分别 169 | 处于 M/S/U 三种特权级时执行 ``ecall`` 这条指令会触发三种陷入。 170 | 171 | .. _term-sbi: 172 | .. _term-abi: 173 | 174 | 在这里我们需要说明一下执行环境调用 ``ecall`` ,这是一种很特殊的会产生 ``陷入`` 的指令, :ref:`上图 ` 中相邻两特权级软件之间的接口正是基于这种陷入 175 | 机制实现的。M 模式软件 SEE 和 S 模式的内核之间的接口被称为 **监督模式二进制接口** (Supervisor Binary Interface, SBI),而内核和 176 | U 模式的应用程序之间的接口被称为 **应用程序二进制接口** (Application Binary Interface, ABI),当然它有一个更加通俗的名字—— **系统调用** 177 | (syscall, System Call) 。而之所以叫做二进制接口,是因为它和在同一种编程语言内部调用接口不同,是汇编指令级的一种接口。事实上 M/S/U 178 | 三个特权级的软件可能分别由不同的编程语言实现,即使是用同一种编程语言实现的,其调用也并不是普通的函数调用执行流,而是**陷入异常控制流** ,在该过程中会 179 | 切换 CPU 特权级。因此只有将接口下降到汇编指令级才能够满足其通用性和灵活性。 180 | 181 | 可以看到,在这样的架构之下,每层特权级的软件都只能做高特权级软件允许它做的、且不会产生什么撼动高特权级软件的事情,一旦低特权级软件的要求超出了其能力范围, 182 | 就必须寻求高特权级软件的帮助。因此,在一条执行流中我们经常能够看到特权级切换。如下图所示: 183 | 184 | .. image:: EnvironmentCallFlow.png 185 | :align: center 186 | :name: environment-call-flow 187 | 188 | .. _term-csr: 189 | 190 | 其他的异常则一般是在执行某一条指令的时候发生了某种错误(如除零、无效地址访问、无效指令等),或处理器认为处于当前特权级下执行当前指令是高特权级指令或会访问不应该访问的高特权级的资源(可能危害系统)。碰到这些情况,就需要需要将控制转交给高特权级的软件(如操作系统)来处理。当处理错误恢复后,则可重新回到低优先级软件去执行;如果不能回复错误,那高特权级软件可以杀死和清除低特权级软件,免破坏整个执行环境。 191 | 192 | .. _term-csr-instr: 193 | 194 | RISC-V的特权指令 195 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 196 | 与特权级无关的一般的指令和通用寄存器 ``x0~x31`` 在任何特权级都可以任意执行。而每个特权级都对应一些特殊指令和 **控制状态寄存器** (CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态。当然特权指令不只是具有有读写 CSR 的指令,还有其他功能的特权指令。 197 | 198 | 如果低优先级下的处理器执行了高优先级的指令,会产生非法指令错误的异常,于是位于高特权级的执行环境能够得知低优先级的软件出现了该错误,这个错误一般是不可恢复的,此时一般它会将上层的低特权级软件终止。这在某种程度上体现了特权级保护机制的作用。 199 | 200 | 在RISC-V中,会有两类低优先级U模式下运行高优先级S模式的指令: 201 | 202 | - 指令本身属于高特权级的指令,如 ``sret`` 指令(表示从S模式返回到U模式)。 203 | - 指令访问了 :ref:`S模式特权级下才能访问的寄存器 ` 或内存,如表示S模式系统状态的 **控制状态寄存器** ``sstatus`` 等。 204 | 205 | .. list-table:: RISC-V S模式特权指令 206 | :align: center 207 | :header-rows: 1 208 | :widths: 30 60 209 | 210 | * - 指令 211 | - 含义 212 | * - sret 213 | - 从S模式返回U模式。在U模式下执行会产生非法指令异常 214 | * - wfi 215 | - 处理器在空闲时进入低功耗状态等待中断。在U模式下执行会尝试非法指令异常 216 | * - sfence.vma 217 | - 刷新TLB缓存。在U模式下执行会尝试非法指令异常 218 | * - 访问S模式CSR的指令 219 | - 通过访问 :ref:`sepc/stvec/scause/sscartch/stval/sstatus/satp等CSR ` 来改变系统状态。在U模式下执行会尝试非法指令异常 220 | 221 | 在下一节中,我们将看到 :ref:`在U模式下的用户态应用程序 ` ,如果执行上述S模式特权指令指令,将会产生非法指令异常,从而看出RISC-V的特权模式设计在一定程度上提供了对操作系统的保护。 222 | 223 | 224 | .. 225 | * - mret 226 | - 从M模式返回S/U模式。在S/U模式下执行会产生非法指令异常 227 | 随着特权级的逐渐降低,硬件的能力受到限制, 228 | 从每一个特权级看来,比它特权级更低的部分都可以看成是它的应用。(这个好像没啥用?) 229 | M 模式是每个 RISC-V CPU 都需要实现的模式,而剩下的模式都是可选的。常见的模式组合:普通嵌入式应用只需要在 M 模式上运行;追求安全的 230 | 嵌入式应用需要在 M/U 模式上运行;像 Unix 这样比较复杂的系统这需要 M/S/U 三种模式。 231 | RISC-V 特权级规范中给出了一些特权寄存器和特权指令... 232 | 重要的是保护,也就是特权级的切换。当 CPU 处于低特权级的时候,如果发生了错误或者一些需要处理的情况,CPU 会切换到高特权级进行处理。这个 233 | 就是所谓的 Trap 机制。 234 | RISC-V 架构规范分为两部分: `RISC-V 无特权级规范 `_ 235 | 和 `RISC-V 特权级规范 `_ 。 236 | RISC-V 无特权级规范中给出的指令和寄存器无论在 CPU 处于哪个特权级下都可以使用。 237 | -------------------------------------------------------------------------------- /source/chapter2/2application.rst: -------------------------------------------------------------------------------- 1 | 实现应用程序以及user文件夹 2 | =========================== 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 5 7 | 8 | 本节导读 9 | ------------------------------- 10 | 11 | 本节主要讲解如何设计实现被批处理系统逐个加载并运行的应用程序。它们是假定在 U 特权级模式运行的前提下而设计、编写的。实际上,如果应用程序的代码都符合它要运行的某特权级的约束,那它完全可能在某特权级中运行。保证应用程序的代码在 U 模式运行是我们接下来将实现的批处理系统的任务。其涉及的设计实现要点是: 12 | 13 | - 应用程序的内存布局 14 | - 应用程序发出的系统调用 15 | 16 | 从某种程度上讲,这里设计的应用程序与第一章中的最小用户态执行环境有很多相同的地方。即设计一个应用程序,能够在用户态通过操作系统提供的服务完成自身的功能。 17 | 18 | user文件夹以及测例简介 19 | --------------------------------------- 20 | 21 | 本章我们引入了用户程序。为了将内核与应用解耦,我们将二者分成了两个仓库,分别是存放内核程序的 ``uCore-Tutorial-Code-20xxx`` (下称代码仓库,最后几位 x 表示学期)与存放用户程序的 ``uCore-Tutorial-Test-20xxx`` (下称测例仓库)。 你首先需要进入代码仓库文件夹并 clone 用户程序仓库(如果已经执行过该步骤则不需要再重复执行): 22 | 23 | .. code-block:: console 24 | 25 | $ git clone https://github.com/LearningOS/uCore-Tutorial-Code-2022S.git 26 | $ cd uCore-Tutorial-Code-2022S 27 | $ git checkout ch2 28 | $ git clone https://github.com/LearningOS/uCore-Tutorial-Test-2022S.git user 29 | 30 | 上面的指令会将测例仓库克隆到代码仓库下并命名为 ``user`` ,注意 ``/user`` 在代码仓库的 ``.gitignore`` 文件中,因此不会出现 ``.git`` 文件夹嵌套的问题,并且你在代码仓库进行 checkout 操作时也不会影响测例仓库的内容。 31 | 32 | 测例实际就是批处理操作系统中一个个待执行的文件。下面我们看一个测例来理解本章以及之后测例的本质: 33 | 34 | .. code-block:: c 35 | 36 | // ch2_hello_world.c 37 | #include 38 | #include 39 | 40 | int main(void) 41 | { 42 | puts("Hello world from user mode program!\nTest hello_world OK!"); 43 | return 0; 44 | } 45 | 46 | 这个测例编译出来实际上就是一个可执行的打印helloworld的程序。如果是windows或者linux上它编译之后是可以直接执行的。它也可以用来检查我们操作系统的实现是否有问题。 47 | 48 | 我们的测例是通过cmake来编译的。具体编译出测例的指令可以参见其中的readme。在使用测例的时候要注意,由于我们使用的是自己的os系统,因此所有常见的C库,比如stdlib.h,stdio.h等等都不能使用C官方的版本。这里在user的include和lib之中我们提供了搭配本次实验的对应库,里面实现了所有测例所需要的函数。大家可以看到,所有测例代码调用的函数都是使用的这里的代码。而这些函数会依赖我们编写的os提供的系统调用(syscall)来完成运行。 49 | 50 | user的库是如何调用到os的系统调用的呢?在user/lib/arch/riscv下的syscall_arch.h为我们包装好了使用riscv汇编调用系统调用ecall的函数接口。lib之中的syscall.c文件就是用这些包装好的函数来进行系统调用实现完整的函数功能。在第一章中大家已经了解了异常委托的机制。U态的ecall指令会转到S态,也就是我们编写的os来进行处理,这样整个逻辑就打通了:为了使得测例成功运行,我们必须实现处理对应ecall的函数。 51 | 52 | 那么现在我们还面临一个理解上的问题:就是测例文件在调用ecall的时候的细节:程序是如何完成特权级切换的?在ecall完毕回到U态的时候,程序又是如何恢复调用ecall之前的执行流并继续执行的呢?这里其实和汇编课程对于异常的处理是一样的,下面我们来复习一下。 53 | 54 | 应用程序的ecall处理流程 55 | ----------------------------- 56 | 57 | ecall作为异常的一种,操作系统和CPU对它的处理方式其实和其他各种异常没什么区别。U态进行ecall调用具体的异常编号是8-Environment call from U-mode.RISCV处理异常需要引入几个特殊的寄存器——CSR寄存器。这些寄存器会记录异常和中断处理流程所需要或保存的各种信息。在上一章中我们看见的mideleg,medeleg寄存器就是CSR寄存器。 58 | 59 | 几个比较关键的CSR寄存器如下: 60 | - scause: 它用于记录异常和中断的原因。它的最高位为1是中断,否则是异常。其低位决定具体的种类。 61 | - sepc:处理完毕中断异常之后需要返回的PC值。 62 | - stval: 产生异常的指令的地址。 63 | - stvec:处理异常的函数的起始地址。 64 | - sstatus:记录一些比较重要的状态,比如是否允许中断异常嵌套。 65 | 66 | 需要注意的是这些寄存器是S态的CSR寄存器。M态还有一套自己的CSR寄存器mcause,mtvec... 67 | 68 | 所以当U态执行ecall指令的时候就产生了异常。此时CPU会处理上述的各个CSR寄存器,之后跳转至stvec所指向的地址,也就是我们的异常处理函数。我们的os的这个函数的具体位置是在trap_init函数之中就指定了——是uservec函数。这个函数位于trampoline.S之中,是由汇编语言编写的。在uservec之中,os保存了U态执行流的各个寄存器的值。这些值的位置其实已经由trap.h中的trapframe结构体规定好了: 69 | 70 | .. code-block:: c 71 | 72 | // os/trap.h 73 | struct trapframe { 74 | /* 0 */ uint64 kernel_satp; // kernel page table 75 | /* 8 */ uint64 kernel_sp; // top of process's kernel stack 76 | /* 16 */ uint64 kernel_trap; // usertrap entry 77 | /* 24 */ uint64 epc; // saved user program counter 78 | /* 32 */ uint64 kernel_hartid; // saved kernel tp, unused in our project 79 | /* 40 */ uint64 ra; 80 | /* 48 */ uint64 sp; 81 | /* ... */ .... 82 | /* 272 */ uint64 t5; 83 | /* 280 */ uint64 t6; 84 | }; 85 | 86 | 由于涉及到直接操作寄存器,因此这里只能使用汇编语言来编写。具体可以参考trampoline.S之中的代码: 87 | 88 | .. code-block:: c 89 | 90 | .section .text 91 | .globl trampoline 92 | trampoline: 93 | .align 4 94 | .globl uservec 95 | uservec: 96 | # 97 | # trap.c sets stvec to point here, so 98 | # traps from user space start here, 99 | # in supervisor mode, but with a 100 | # user page table. 101 | # 102 | # sscratch points to where the process's p->trapframe is 103 | # mapped into user space, at TRAPFRAME. 104 | # 105 | 106 | # swap a0 and sscratch 107 | # so that a0 is TRAPFRAME 108 | csrrw a0, sscratch, a0 109 | 110 | # save the user registers in TRAPFRAME 111 | sd ra, 40(a0) 112 | ... 113 | sd t6, 280(a0) 114 | 115 | # save the user a0 in p->trapframe->a0 116 | csrr t0, sscratch 117 | sd t0, 112(a0) 118 | 119 | csrr t1, sepc 120 | sd t1, 24(a0) 121 | 122 | ld sp, 8(a0) 123 | ld tp, 32(a0) 124 | ld t1, 0(a0) 125 | # csrw satp, t1 126 | # sfence.vma zero, zero 127 | ld t0, 16(a0) 128 | jr t0 129 | 130 | 这里需要注意sscratch这个CSR寄存器的作用就是一个cache,它只负责存某一个值,这里它保存的就是TRAPFRAME结构体的位置。csrr和csrrw指令是RV特供的读写CSR寄存器的指令。我们取用它的值的时候实际把原来a0的值和其交换了,因此返回时大家可以看到我们会再交换一次得到原来的a0。这里注释了两句代码大家可以不用管,这是页表相关的处理,我们在ch4会仔细了解它。 131 | 132 | 然后我们使用jr t0,就跳转到了我们早先设定在 trapframe->kernel_trap 中的地址,也就是 trap.c 之中的 usertrap 函数。这个函数在main的初始化之中已经调用了。 133 | 134 | .. code-block:: c 135 | 136 | // os/trap.c 137 | // set up to take exceptions and traps while in the kernel. 138 | void trapinit(void) 139 | { 140 | w_stvec((uint64)uservec & ~0x3); // 写 stvec, 最后两位表明跳转模式,该实验始终为 0 141 | } 142 | 143 | 该函数完成异常中断处理与返回,包括执行我们写好的syscall。 144 | 145 | 从S态返回U态是由 usertrapret 函数实现的。这里设置了返回地址sepc,并调用另外一个 userret 汇编函数来恢复 trapframe 结构体之中的保存的U态执行流数据。 146 | 147 | .. code-block:: c 148 | 149 | void usertrapret(struct trapframe *trapframe, uint64 kstack) 150 | { 151 | trapframe->kernel_satp = r_satp(); // kernel page table 152 | trapframe->kernel_sp = kstack + PGSIZE; // process's kernel stack 153 | trapframe->kernel_trap = (uint64)usertrap; 154 | trapframe->kernel_hartid = r_tp(); // hartid for cpuid() 155 | 156 | w_sepc(trapframe->epc); // 设置了sepc寄存器的值。 157 | // set up the registers that trampoline.S's sret will use 158 | // to get to user space. 159 | 160 | // set S Previous Privilege mode to User. 161 | uint64 x = r_sstatus(); 162 | x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode 163 | x |= SSTATUS_SPIE; // enable interrupts in user mode 164 | w_sstatus(x); 165 | 166 | // tell trampoline.S the user page table to switch to. 167 | // uint64 satp = MAKE_SATP(p->pagetable); 168 | userret((uint64)trapframe); 169 | } 170 | 171 | 同样由于涉及寄存器的恢复,以及未来页表satp寄存器的设置等,userret也必须是一个汇编函数。它基本上就是uservec函数的镜像,将保存在trapframe之中的数据依次读出用于恢复对应的寄存器,实现恢复用户中断前的状态。 172 | 173 | .. code-block:: c 174 | 175 | .globl userret 176 | userret: 177 | # userret(TRAPFRAME, pagetable) 178 | # switch from kernel to user. 179 | # usertrapret() calls here. 180 | # a0: TRAPFRAME, in user page table. 181 | # a1: user page table, for satp. 182 | 183 | # switch to the user page table.在第四章才会有具体作用。 184 | csrw satp, a1 185 | sfence.vma zero, zero 186 | 187 | # put the saved user a0 in sscratch, so we 188 | # can swap it with our a0 (TRAPFRAME) in the last step. 189 | ld t0, 112(a0) 190 | csrw sscratch, t0 191 | 192 | # restore all but a0 from TRAPFRAME 193 | ld ra, 40(a0) 194 | ld sp, 48(a0) 195 | ld gp, 56(a0) 196 | ld tp, 64(a0) 197 | ld t0, 72(a0) 198 | ld t1, 80(a0) 199 | ld t2, 88(a0) 200 | ... 201 | ld t4, 264(a0) 202 | ld t5, 272(a0) 203 | ld t6, 280(a0) 204 | 205 | # restore user a0, and save TRAPFRAME in sscratch 206 | csrrw a0, sscratch, a0 207 | 208 | # return to user mode and user pc. 209 | # usertrapret() set up sstatus and sepc. 210 | sret 211 | 212 | 需要注意最后执行的sret指令执行了2个事情:从S态回到U态,并将PC移动到sepc指定的位置,继续执行用户程序。 213 | 214 | 这个过程中还有一些细节,大家将在课后习题中慢慢品味。 -------------------------------------------------------------------------------- /source/chapter2/3batch-system.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _term-batchos: 3 | 4 | 实现批处理操作系统的细节 5 | ============================== 6 | 7 | .. toctree:: 8 | :hidden: 9 | :maxdepth: 5 10 | 11 | 本节导读 12 | ------------------------------- 13 | 14 | 前面一节中我们明白了os是如何执行应用程序的。但是os是如何”找到“这些应用程序并允许它们的呢?在引言之中我们简要介绍了这是由link_app.S以及kernel_app.ld完成的。实际上,能够在批处理操作系统与应用程序之间建立联系的纽带。这主要包括两个方面: 15 | 16 | - 静态编码:通过一定的编程技巧,把应用程序代码和批处理操作系统代码“绑定”在一起。 17 | - 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。 18 | 19 | 这里与硬件相关且比较困难的地方是如何让在内核态的批处理操作系统启动应用程序,且能让应用程序在用户态正常执行。 20 | 21 | 将应用程序链接到内核 22 | -------------------------------------------- 23 | 24 | makefile更新 25 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 26 | 27 | 我们首先看一看本章的makefile改变了什么 28 | 29 | .. code-block:: Makefile 30 | 31 | link_app.o: link_app.S 32 | link_app.S: pack.py 33 | @$(PY) pack.py 34 | kernel_app.ld: kernelld.py 35 | @$(PY) kernelld.py 36 | 37 | kernel: $(OBJS) kernel_app.ld link_app.S 38 | $(LD) $(LDFLAGS) -T kernel_app.ld -o kernel $(OBJS) 39 | $(OBJDUMP) -S kernel > kernel.asm 40 | $(OBJDUMP) -t kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > kernel.sym 41 | 42 | 可以看到makefile执行了两个python脚本生成了我们提到的link_app.S和kernel_app.ld。这里选择python只是因为比较好写生成的代码,我们的os和python没有任何关系。link_app.S的大致内容如下:: 43 | 44 | .align 4 45 | .section .data 46 | .global _app_num 47 | _app_num: 48 | .quad 2 49 | .quad app_0_start 50 | .quad app_1_start 51 | .quad app_1_end 52 | 53 | .global _app_names 54 | _app_names: 55 | .string "hello.bin" 56 | .string "matrix.bin" 57 | 58 | .section .data.app0 59 | .global app_0_start 60 | app_0_start: 61 | .incbin "../user/target/bin/ch2t_write0.bin" 62 | 63 | .section .data.app1 64 | .global app_1_start 65 | app_1_start: 66 | .incbin "../user/target/bin/ch2b_write1.bin" 67 | app_1_end: 68 | 69 | pack.py会遍历../user/target/bin,并将该目录下的目标用户程序*.bin包含入 link_app.S中,同时给每一个bin文件记录其地址和名称信息。最后,我们在 Makefile 中会将内核与 link_app.S 一同编译并链接。这样,我们在内核中就可以通过 extern 指令访问到用户程序的所有信息,如其文件名等。 70 | 71 | 由于 riscv 要求程序指令必须是对齐的,我们对内核链接脚本也作出修改,保证用户程序链接时的指令对齐,这些内容见 os/kernelld.py。这个脚本也会遍历../user/target/,并对每一个bin文件分配对齐的空间。最终修改后的kernel_app.ld脚本中多了如下对齐要求:: 72 | 73 | .data : { 74 | *(.data) 75 | . = ALIGN(0x1000); 76 | *(.data.app0) 77 | . = ALIGN(0x1000); 78 | *(.data.app1) 79 | . = ALIGN(0x1000); 80 | *(.data.app2) 81 | . = ALIGN(0x1000); 82 | *(.data.app3) 83 | . = ALIGN(0x1000); 84 | *(.data.app4) 85 | 86 | *(.data.*) 87 | } 88 | 89 | 编译出的kernel已经包含了bin文件的信息。熟悉汇编的同学可以去看看生成的kernel.asm(kernel整体的汇编代码)来加深理解。 90 | 91 | 内核的relocation 92 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 93 | 94 | 内核中通过访问 link_app.S 中定义的 _app_num、app_0_start 等符号来获得用户程序位置. 95 | 96 | .. code-block:: c 97 | 98 | // os/batch.c 99 | extern char _app_num[]; // 在link_app.S之中已经定义 100 | void batchinit() { 101 | app_info_ptr = (uint64*) _app_num; 102 | app_num = *app_info_ptr; 103 | app_info_ptr++; 104 | // from now on: 105 | // app_n_start = app_info_ptr[n] 106 | // app_n_end = app_info_ptr[n+1] 107 | } 108 | 109 | 然而我们并不能直接跳转到 app_n_start 直接运行,因为用户程序在编译的时候,会假定程序处在虚存的特定位置,而由于我们还没有虚存机制,因此我们在运行之前还需要将用户程序加载到规定的物理内存位置。为此我们规定了用户的链接脚本,并在内核完成程序的 "搬运" 110 | 111 | .. code-block:: c 112 | 113 | # user/lib/arch/riscv/user.ld 114 | SECTIONS { 115 | . = 0x80400000; # 规定了内存加载位置 116 | 117 | .startup : { 118 | *crt.S.o(.text) # 确保程序入口在程序开头 119 | } 120 | 121 | .text : { *(.text) } 122 | .data : { *(.data .rodata) } 123 | 124 | /DISCARD/ : { *(.eh_*) } 125 | } 126 | 127 | 这样之后,我们就可以在读取指定内存位置的bin文件来执行它们了。下面是os内核读取link_app.S的info并把它们搬运到0x80400000开始位置的具体过程。 128 | 129 | .. code-block:: c 130 | 131 | // os/batch.c 132 | const uint64 BASE_ADDRESS = 0x80400000, MAX_APP_SIZE = 0x20000; 133 | int load_app(uint64* info) { 134 | uint64 start = info[0], end = info[1], length = end - start; 135 | memset((void*)BASE_ADDRESS, 0, MAX_APP_SIZE); 136 | memmove((void*)BASE_ADDRESS, (void*)start, length); 137 | return length; 138 | } 139 | 140 | 141 | 用户栈与内核栈 142 | -------------------------------------------- 143 | 144 | 我们自己的OS内核运行时,是需要一个栈来存放自己需要的变量的,这个栈我们称之为内核栈。在RV之中,我们使用sp寄存器来记录当前栈顶的位置。因此,在进入OS之前,我们需要告诉qemu我们OS的内核栈的起始位置。这个在entry.S之中有实现:: 145 | 146 | // entry.S 147 | _entry: 148 | la sp, boot_stack_top 149 | call main 150 | 151 | .section .bss.stack 152 | .globl boot_stack 153 | boot_stack: 154 | .space 4096 * 16 155 | .globl boot_stack_top 156 | 157 | 一个应用程序肯定也需要内存空间来存放执行时需要的种种变量(实际上就是执行程序对应的用户栈),同时我们在上一章节提到了trapframe,这个也需要一个空间存放。那么OS是如何给应用程序分配这些对应的空间的呢? 158 | 159 | 实际上,我们采用一个静态分配的方式来给程序分配对应的一定大小的空间,并在run_next_app函数初始化应用程序对应的trapframe,并将用户栈对应的起始位置写入trapframe之中的sp寄存器,来让程序找到自己用户栈起始的位置。(注意栈在空间是高到低位,因此这里起始位置的初始化是在静态分配数组的尾部)。 160 | 161 | .. code-block:: c 162 | 163 | // loader.c 164 | __attribute__((aligned(4096))) char user_stack[USER_STACK_SIZE]; 165 | __attribute__((aligned(4096))) char trap_page[TRAP_PAGE_SIZE]; 166 | 167 | int run_next_app() 168 | { 169 | struct trapframe *trapframe = (struct trapframe *)trap_page; 170 | ... 171 | memset(trapframe, 0, 4096); 172 | trapframe->epc = BASE_ADDRESS; 173 | trapframe->sp = (uint64)user_stack + USER_STACK_SIZE; 174 | usertrapret(trapframe, (uint64)boot_stack_top); 175 | ... 176 | } 177 | 178 | 到这里,一个应用程序就算真正完全加载进入了内存之中进入就绪状态,可以随时运行了。 179 | 180 | 181 | -------------------------------------------------------------------------------- /source/chapter2/4exercise.rst: -------------------------------------------------------------------------------- 1 | chapter2练习(已废弃) 2 | ===================================================== 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 4 7 | 8 | - 本节难度: **低** 9 | 10 | 本节任务 11 | ----------------------------------- 12 | - 注意 ch2 并不对应一次 lab 提交,本节任务在 ch3 最终提交。 13 | - 运行 ch2 分支的框架代码,确认 ``BASE=1`` 时 os 运行正常。 14 | - 理解目前 os 加载用户程序的整体逻辑。在 ``user`` 目录下执行 ``make CHAPTER=2_bad`` 生成三个会导致错误的程序,运行并描述他们导致的现象。完成问答作业第一题。 15 | - 阅读状态切换相关函数,思考并完成问答作业第二题。 16 | - 完成问答作业第三题。 17 | 18 | 编程练习 19 | ------------------------------- 20 | 无 21 | 22 | .. ch2问答作业:: 23 | 24 | 问答作业 25 | ------------------------------- 26 | 27 | 无 -------------------------------------------------------------------------------- /source/chapter2/EnvironmentCallFlow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter2/EnvironmentCallFlow.png -------------------------------------------------------------------------------- /source/chapter2/PrivilegeStack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter2/PrivilegeStack.png -------------------------------------------------------------------------------- /source/chapter2/ch2.py: -------------------------------------------------------------------------------- 1 | from manimlib.imports import * 2 | 3 | class EnvironmentCallFlow(Scene): 4 | CONFIG = { 5 | "camera_config": { 6 | "background_color": WHITE, 7 | }, 8 | } 9 | def construct(self): 10 | os = Rectangle(height=FRAME_HEIGHT*.8, width=2.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0) 11 | app = Rectangle(height=FRAME_HEIGHT*.8, width=2.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0) 12 | app.shift(np.array([-4, 0, 0])) 13 | see = Rectangle(height=FRAME_HEIGHT*.8, width=2.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0) 14 | see.shift(np.array([4, 0, 0])) 15 | self.add(os, app, see) 16 | os_text = TextMobject("OS", color=BLACK).next_to(os, UP, buff=0.2) 17 | app_text = TextMobject("Application", color=BLACK).next_to(app, UP, buff=0.2) 18 | see_text = TextMobject("SEE", color=BLACK).next_to(see, UP, buff=.2) 19 | self.add(os_text, app_text, see_text) 20 | app_ecall = Rectangle(height=0.5, width=2.0, color=BLACK, fill_color=BLUE, fill_opacity=1.0) 21 | app_ecall.move_to(app) 22 | app_ecall_text = TextMobject("ecall", color=BLACK).move_to(app_ecall) 23 | self.add(app_ecall, app_ecall_text) 24 | app_code1 = Rectangle(width=2.0, height=2.0, color=BLACK, fill_color=GREEN, fill_opacity=1.0) 25 | app_code1.next_to(app_ecall, UP, buff=0) 26 | self.add(app_code1) 27 | app_code2 = Rectangle(width=2.0, height=2.5, color=BLACK, fill_color=GREEN, fill_opacity=1.0) 28 | app_code2.next_to(app_ecall, DOWN, buff=0) 29 | self.add(app_code2) 30 | app_code1_text = TextMobject("U Code", color=BLACK).move_to(app_code1).shift(np.array([-.15, 0, 0])) 31 | app_code2_text = TextMobject("U Code", color=BLACK).move_to(app_code2).shift(np.array([-.15, 0, 0])) 32 | self.add(app_code1_text, app_code2_text) 33 | os_ecall = Rectangle(height=.5, width=2.0, color=BLACK, fill_color=BLUE, fill_opacity=1.0) 34 | os_ecall.move_to(os) 35 | os_ecall_text = TextMobject("ecall", color=BLACK).move_to(os_ecall) 36 | self.add(os_ecall, os_ecall_text) 37 | os_code1 = Rectangle(width=2.0, height=2.0, color=BLACK, fill_color=PURPLE, fill_opacity=1.0).next_to(os_ecall, UP, buff=0) 38 | os_code1_text = TextMobject("S Code", color=BLACK).move_to(os_code1).shift(np.array([-.15, 0, 0])) 39 | os_code2 = Rectangle(width=2.0, height=2.5, color=BLACK, fill_color=PURPLE, fill_opacity=1.0).next_to(os_ecall, DOWN, buff=0) 40 | os_code2_text = TextMobject("S Code", color=BLACK).move_to(os_code2).shift(np.array([-.15, 0, 0])) 41 | self.add(os_code1, os_code2, os_code1_text, os_code2_text) 42 | app_ecall_anchor = app_ecall.get_center() + np.array([0.8, 0, 0]) 43 | app_front = Line(start=app_ecall_anchor+np.array([0, 2, 0]), end=app_ecall_anchor, color=RED) 44 | app_front.add_tip(tip_length=0.2) 45 | self.add(app_front) 46 | os_ecall_anchor = os_ecall.get_center() + np.array([0.8, 0, 0]) 47 | os_front = Line(start=os_ecall_anchor+np.array([0, 2, 0]), end=os_ecall_anchor, color=RED) 48 | os_front.add_tip(tip_length=.2) 49 | self.add(os_front) 50 | trap_to_os = DashedLine(start=app_ecall_anchor, end=os_ecall_anchor+np.array([0, 2, 0]), color=RED) 51 | trap_to_os.add_tip(tip_length=.2) 52 | self.add(trap_to_os) 53 | see_entry = see.get_center()+np.array([0.8, 2, 0]) 54 | see_exit = see_entry+np.array([0, -4, 0]) 55 | see_code = Rectangle(width=2.0, height=see_entry[1]-see_exit[1], color=BLACK, fill_color=GRAY, fill_opacity=1.0).move_to(see) 56 | self.add(see_code) 57 | see_text = TextMobject("M Code", color=BLACK).move_to(see_code).shift(np.array([-.15, 0, 0])) 58 | self.add(see_text) 59 | see_front = Line(start=see_entry, end=see_exit, color=RED).add_tip(tip_length=.2) 60 | self.add(see_front) 61 | trap_to_see = DashedLine(start=os_ecall_anchor, end=see_entry, color=RED).add_tip(tip_length=.2) 62 | self.add(trap_to_see) 63 | os_back_anchor = os_ecall_anchor+np.array([0, -.5, 0]) 64 | trap_back_to_os = DashedLine(start=see_exit, end=os_back_anchor, color=RED).add_tip(tip_length=.2) 65 | self.add(trap_back_to_os) 66 | os_exit = os_back_anchor+np.array([0, -2, 0]) 67 | os_front2 = Line(start=trap_back_to_os, end=os_exit, color=RED).add_tip(tip_length=.2) 68 | self.add(os_front2) 69 | app_back_anchor = app_ecall_anchor+np.array([0, -.5, 0]) 70 | trap_back_to_app = DashedLine(start=os_exit, end=app_back_anchor, color=RED).add_tip(tip_length=.2) 71 | self.add(trap_back_to_app) 72 | app_front2 = Line(start=app_back_anchor, end=app_back_anchor+np.array([0, -2, 0]), color=RED) 73 | app_front2.add_tip(tip_length=.2) 74 | self.add(app_front2) 75 | u_into_s = TextMobject("U into S", color=BLACK).next_to(app_ecall, RIGHT, buff=0).shift(np.array([0, .5, 0])).scale(0.5) 76 | s_back_u = TextMobject("S back to U", color=BLACK).next_to(app_ecall, RIGHT, buff=0).shift(np.array([-.3, -1, 0])).scale(0.5) 77 | s_into_m = TextMobject("S into M", color=BLACK).next_to(os_ecall, RIGHT, buff=0).shift(np.array([0, .5, 0])).scale(.5) 78 | m_back_s = TextMobject("M back to S", color=BLACK).next_to(os_ecall, RIGHT, buff=0).shift(np.array([-.3, -1, 0])).scale(.5) 79 | self.add(u_into_s, s_back_u, s_into_m, m_back_s) 80 | 81 | 82 | 83 | class PrivilegeStack(Scene): 84 | CONFIG = { 85 | "camera_config": { 86 | "background_color": WHITE, 87 | }, 88 | } 89 | def construct(self): 90 | os = Rectangle(width=4.0, height=1.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0) 91 | os_text = TextMobject("OS", color=BLACK).move_to(os) 92 | self.add(os, os_text) 93 | 94 | sbi = Rectangle(width=4.0, height=1.0, color=BLACK, fill_color=BLACK, fill_opacity=1.0) 95 | sbi.next_to(os, DOWN, buff=0) 96 | sbi_text = TextMobject("SBI", color=WHITE).move_to(sbi) 97 | self.add(sbi, sbi_text) 98 | 99 | see = Rectangle(width=4.0, height=1.0, color=BLACK, fill_color=WHITE, fill_opacity=1.0) 100 | see.next_to(sbi, DOWN, buff=0) 101 | see_text = TextMobject("SEE", color=BLACK).move_to(see) 102 | self.add(see, see_text) 103 | 104 | abi0 = Rectangle(height=1.0, width=1.8, color=BLACK, fill_color=BLACK, fill_opacity=1.0) 105 | abi0.next_to(os, UP, buff=0).align_to(os, LEFT) 106 | abi0_text = TextMobject("ABI", color=WHITE).move_to(abi0) 107 | self.add(abi0, abi0_text) 108 | 109 | abi1 = Rectangle(height=1.0, width=1.8, color=BLACK, fill_color=BLACK, fill_opacity=1.0) 110 | abi1.next_to(os, UP, buff=0).align_to(os, RIGHT) 111 | abi1_text = TextMobject("ABI", color=WHITE).move_to(abi1) 112 | self.add(abi1, abi1_text) 113 | 114 | app0 = Rectangle(height=1.0, width=1.8, color=BLACK, fill_color=WHITE, fill_opacity=1.0) 115 | app0.next_to(abi0, UP, buff=0) 116 | app0_text = TextMobject("App", color=BLACK).move_to(app0) 117 | self.add(app0, app0_text) 118 | 119 | app1 = Rectangle(height=1.0, width=1.8, color=BLACK, fill_color=WHITE, fill_opacity=1.0) 120 | app1.next_to(abi1, UP, buff=0) 121 | app1_text = TextMobject("App", color=BLACK).move_to(app1) 122 | self.add(app1, app1_text) 123 | 124 | line0 = DashedLine(sbi.get_right(), sbi.get_right() + np.array([3, 0, 0]), color=BLACK) 125 | self.add(line0) 126 | line1 = DashedLine(abi1.get_right(), abi1.get_right() + np.array([3, 0, 0]), color=BLACK) 127 | self.add(line1) 128 | 129 | machine = TextMobject("Machine", color=BLACK).next_to(see, RIGHT, buff=.8) 130 | supervisor = TextMobject("Supervisor", color=BLACK).next_to(os, RIGHT, buff=.8) 131 | user = TextMobject("User", color=BLACK).next_to(app1, RIGHT, buff=.8) 132 | self.add(machine, supervisor, user) 133 | 134 | -------------------------------------------------------------------------------- /source/chapter2/deng-fish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter2/deng-fish.png -------------------------------------------------------------------------------- /source/chapter2/index.rst: -------------------------------------------------------------------------------- 1 | .. _link-chapter2: 2 | 3 | 第二章:批处理系统 4 | ============================================== 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | 0intro 10 | 1rv-privilege 11 | 2application 12 | 3batch-system 13 | 14 | -------------------------------------------------------------------------------- /source/chapter3/0intro.rst: -------------------------------------------------------------------------------- 1 | 引言 2 | ======================================== 3 | 4 | 本章导读 5 | -------------------------- 6 | 7 | 8 | .. 9 | chyyuu:有一个ascii图,画出我们做的OS。 10 | 11 | 12 | 本章展现了操作系统一系列功能: 13 | 14 | - 通过提前加载应用程序到内存,减少应用程序切换开销 15 | - 通过协作机制支持程序主动放弃处理器,提高系统执行效率 16 | - 通抢占机制支持程序被动放弃处理器,提高不同程序对处理器资源使用的公平性,也进一步提高了应用对I/O事件的响应效率 17 | 18 | 19 | 上一章,我们实现了一个简单的批处理系统。首先,它能够自动按照顺序加载并运行序列中的每一个应用,当一个应用运行结束之后无需操作员的手动替换;另一方面,在硬件提供的特权级机制的帮助下,运行在更高特权级的它不会受到有意或者无意出错的应用的影响,可以全方位监控运行在用户态特权级的应用的执行,一旦应用越过了硬件所设置特权级界限或主动申请获得操作系统的服务,就会触发 Trap 并进入到批处理系统中进行处理。无论原因是应用出错或是应用声明自己执行完毕,批处理系统都只需要加载序列中的下一个应用并进入执行。可以看到批处理系统的特性是:在内存中同一时间最多只需驻留一个应用。这是因为只有当一个应用出错或退出之后,批处理系统才会去将另一个应用加载到相同的一块内存区域。 20 | 21 | 而计算机硬件在快速发展,内存容量在逐渐增大,处理器的速度也在增加,外设IO性能方面的进展不大。这就使得以往内存只能放下一个程序的情况得到很大改善,但处理器的空闲程度加大了。于是科学家就开始考虑在内存中尽量同时驻留多个应用,这样处理器的利用率就会提高。但只有一个程序执行完毕后或主动放弃执行,处理器才能执行另外一个程序。这种运行方式称为 **多道程序** 。 22 | 23 | 24 | 协作式操作系统 25 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 26 | 27 | 早期的计算机系统大部分是单处理器计算机系统。当处理器进一步发展后,它与IO的速度差距也进一步拉大。这时计算机科学家发现,在 **多道程序** 运行方式下,一个程序如果不让出处理器,其他程序是无法执行的。如果一个应用由于IO操作让处理器空闲下来或让处理器忙等,那其他需要处理器资源进行计算的应用还是没法使用空闲的处理器资源。于是就想到,让应用在执行IO操作时,可以主动 **释放处理器** ,让其他应用继续执行。当然执行 **放弃处理器** 的操作算是一种对处理器资源的直接管理,所以应用程序可以发出这样的系统调用,让操作系统来具体完成。这样的操作系统就是支持 **多道程序** 协作式操作系统。 28 | 29 | 抢占式操作系统 30 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 31 | 32 | 计算机科学家很快发现,编写应用程序的科学家(简称应用程序员)来自不同的领域,他们不一定有友好互助的意识,也不了解其他程序的执行情况,很难(也没必要)有提高整个系统利用率上的大局观。在他们的脑海里,整个计算机就应该是为他们自己的应用准备的,不用考虑其他程序的运行。这导致应用程序员在编写程序时,无法做到在程序的合适位置放置 **放弃处理器的系统调用请求** ,这样系统的整体利用率还是无法提高。 33 | 34 | 所以,站在系统的层面,还是需要有一种办法能强制打断应用程序的执行,来提高整个系统的效率,让在整个系统中执行的多个程序之间占用计算机资源的情况相对公平一些。根据计算机系统的硬件设计,为提高I/O效率,外设可以通过硬件中断机制来与处理机进行I/O交互操作。这种硬件中断机制·可随时打断应用程序的执行,并让操作系统来完成对外设的I/O响应。 35 | 36 | 而操作系统可进一步利用某种以固定时长为时间间隔的外设中断(比如时钟中断)来强制打断一个程序的执行,这样一个程序只能运行一段时间(可以简称为一个时间片, Time Slice)就一定会让出处理器,且操作系统可以在处理外设的I/O响应后,让不同应用程序分时占用处理器执行,并可通过程序占用处理器的总执行时间来评估运行的程序对处理器资源的消耗。 37 | 38 | .. _term-task: 39 | 40 | 我们可以把一个程序在一个时间片上占用处理器执行的过程称为一个 **任务** (Task),让操作系统对不同程序的 **任务** 进行管理。通过平衡各个程序在整个时间段上的任务数,就达到一定程度的系统公平和高效的系统效率。在一个包含多个时间片的时间段上,会有属于不同程序的多个任务在轮流占用处理器执行,这样的操作系统就是支持 **分时多任务** 的抢占式操作系统。 41 | 42 | 43 | 本章所介绍的多道程序和分时多任务系统都有一些共同的特点:在内存中同一时间可以驻留多个应用。所有的应用都是在系统启动的时候分别加载到内存的不同区域中。由于目前计算机系统中只有一个处理器,则同一时间最多只有一个应用在执行,剩下的应用则处于就绪状态,需要内核将处理器分配给它们才能开始执行。一旦应用开始执行,它就处于运行状态了。 44 | 45 | 本章我们主要是着眼于抢占式的支持多进程的操作系统,通过时钟中断来达到进程的切换。当然,我们的OS也支持进程主动放弃执行。具体请阅读之后的章节。 46 | 47 | 48 | .. note:: 49 | 50 | 读者也许会有疑问:由于只有一个 处理器,即使这样做,同一时间最多还是只能运行一个应用,还浪费了更多的内存来把所有 51 | 的应用都加载进来。那么这样做有什么意义呢? 52 | 53 | 读者可以带着这个问题继续看下去。后面我们会介绍这样做到底能够解决什么问题。 54 | 55 | 实践体验 56 | ------------------------------------- 57 | 58 | .. _term-multiprogramming: 59 | .. _term-time-sharing-multitasking: 60 | 61 | 获取分时多任务系统的代码: 62 | 63 | .. code-block:: console 64 | 65 | $ git checkout ch3 66 | 67 | 在 qemu 模拟器上运行本章代码: 68 | 69 | .. code-block:: console 70 | 71 | $ make test BASE=1 72 | 73 | 多道程序的应用分别会输出一个不同的字母矩阵。当他们交替执行的时候,我们将看到字母行的交错输出: 74 | 75 | .. code-block::bash 76 | 77 | AAAAAAAAAA [1/5] 78 | CCCCCCCCCC [1/5] 79 | BBBBBBBBBB [1/5] 80 | AAAAAAAAAA [2/5] 81 | CCCCCCCCCC [2/5] 82 | BBBBBBBBBB [2/5] 83 | AAAAAAAAAA [3/5] 84 | CCCCCCCCCC [3/5] 85 | BBBBBBBBBB [3/5] 86 | AAAAAAAAAA [4/5] 87 | CCCCCCCCCC [4/5] 88 | BBBBBBBBBB [4/5] 89 | AAAAAAAAAA [5/5] 90 | CCCCCCCCCC [5/5] 91 | BBBBBBBBBB [5/5] 92 | Test write A OK! 93 | Test write C OK! 94 | Test write B OK! 95 | 96 | 另外需要说明的是一点是:与上一章不同,应用的编号不再决定其被加载运行的先后顺序,而仅仅能够改变应用被加载到内存中的位置。 97 | 98 | 本章代码树 99 | --------------------------------------------- 100 | 101 | .. code-block::bash 102 | :linenos: 103 | :emphasize-lines: 14 104 | 105 | . 106 | ├── bootloader 107 | │ └── rustsbi-qemu.bin 108 | ├── LICENSE 109 | ├── Makefile 110 | ├── os 111 | │ ├── console.c 112 | │ ├── console.h 113 | │ ├── const.h 114 | │ ├── defs.h 115 | │ ├── entry.S 116 | │ ├── kernel.ld 117 | │ ├── kernelld.py 118 | │ ├── loader.c 119 | │ ├── loader.h 120 | │ ├── log.h 121 | │ ├── main.c 122 | │ ├── pack.py 123 | │ ├── printf.c 124 | │ ├── printf.h 125 | │ ├── proc.c 126 | │ ├── proc.h 127 | │ ├── riscv.h 128 | │ ├── sbi.c 129 | │ ├── sbi.h 130 | │ ├── string.c 131 | │ ├── string.h 132 | │ ├── switch.S 133 | │ ├── syscall.c 134 | │ ├── syscall.h 135 | │ ├── syscall_ids.h 136 | │ ├── timer.c 137 | │ ├── timer.h 138 | │ ├── trampoline.S 139 | │ ├── trap.c 140 | │ ├── trap.h 141 | │ └── types.h 142 | ├── README.md 143 | ├── scripts 144 | │ ├── kernelld.py 145 | │ └── pack.py 146 | └── user 147 | 148 | 149 | 本章代码导读 150 | ----------------------------------------------------- 151 | 152 | 随着章节的推进,我们的OS从测例嵌入main.c到批处理的OS系统,再到本章的多进程OS,可以说我们的OS已经初具雏形了。在进入本章的内容之前,大家需要对进程有一个清晰的认识。进程就是“执行中的程序”,因此每一个进程需要有程序运行所需要的资源。每一个进程又有着自己的优先级,使得进程在调度的时候存在某种机制使得高优先级的进程能够优先执行,而低优先级的进程又不能永远不执行。我们本章就要设计一个支持优先级进程调度的OS。 153 | 154 | 同时,进程的引入也意味着同时存在多个app在运行,这意味着我们需要一次将多个测例的bin文件移动到指定的内存位置之中,并且为每一个bin都做好配套的初始化。 -------------------------------------------------------------------------------- /source/chapter3/1multi-loader.rst: -------------------------------------------------------------------------------- 1 | 多道程序放置与加载 2 | ===================================== 3 | 4 | 本节导读 5 | -------------------------- 6 | 7 | 在本章的引言中我们提到每个应用都需要按照它的编号被分别放置并加载到内存中不同的位置。本节我们就来介绍它是如何实现的。通过具体实现,可以看到多个应用程序被一次性地加载到内存中,这样在切换到另外一个应用程序执行会很快,不像前一章介绍的操作系统,还要有清空前一个应用,然后加载当前应用的过程与开销。 8 | 9 | 但我们也会了解到,每个应用程序需要知道自己运行时在内存中的不同位置,这对应用程序的编写带来了一定的麻烦。而且操作系统也要知道每个应用程序运行时的位置,不能任意移动应用程序所在的内存空间,即不能在运行时根据内存空间的动态空闲情况,把应用程序调整到合适的空闲空间中。 10 | 11 | .. 12 | chyyuu:有一个ascii图,画出我们做的OS在本节的部分。 13 | 14 | 多道程序的放置 15 | ---------------------------- 16 | 17 | 我们仍然使用pack.py以及kernel.py来生成链接测例bin文件的link_app.S以及kernel_app.ld两个文件。这些内容相较第二章并没有任何改变。主要改变的是对多道程序的加载上,要求同时加载多个程序了。 18 | 19 | 多道程序加载 20 | ---------------------------- 21 | 22 | 从本章开始不再使用上一章批处理操作系统的run_next_app函数。让我们看看loader.c文件之中修改了什么。 23 | 24 | .. code-block:: c 25 | :linenos: 26 | 27 | // os/loader.c 28 | 29 | int load_app(int n, uint64* info) { 30 | uint64 start = info[n], end = info[n+1], length = end - start; 31 | memset((void*)BASE_ADDRESS + n * MAX_APP_SIZE, 0, MAX_APP_SIZE); 32 | memmove((void*)BASE_ADDRESS + n * MAX_APP_SIZE, (void*)start, length); 33 | return length; 34 | } 35 | 36 | // load all apps and init the corresponding `proc` structure. 37 | // 这个函数的更过细节在之后讲解 38 | int run_all_app() 39 | { 40 | for (int i = 0; i < app_num; ++i) { 41 | struct proc *p = allocproc(); 42 | struct trapframe *trapframe = p->trapframe; 43 | load_app(i, app_info_ptr); 44 | uint64 entry = BASE_ADDRESS + i * MAX_APP_SIZE; 45 | trapframe->epc = entry; 46 | trapframe->sp = (uint64)p->ustack + USER_STACK_SIZE; 47 | p->state = RUNNABLE; 48 | } 49 | return 0; 50 | } 51 | 52 | 可以看到,进程 load 的逻辑其实没有变化。但是我们在run_all_app函数之中一次性加载了所有的测例程序。具体的方式是遍历每一个app获取其放置的位置,并根据其序号i设置相对于BASE_ADDRESS的偏移量作为程序的起始位置。我们认为设定每个进程所使用的空间是 [0x80400000 + i*0x20000, 0x80400000 + (i+1)*0x20000),每个进程的最大 size 为 0x20000,i 即为进程编号。需要注意此时同时完成了每一个程序对应的进程的初始化以及状态的设置(对进程还不熟悉的同学可以阅读下一节)。 53 | 54 | 现在应用程序加载完毕了。不同于批处理操作系统,我们该如何执行它们呢? -------------------------------------------------------------------------------- /source/chapter3/2proc-basic.rst: -------------------------------------------------------------------------------- 1 | 进程基础结构 2 | ================================ 3 | 4 | 本节导读 5 | -------------------------- 6 | 7 | 本节会介绍进程的调度方式。这是本章的重点之一。 8 | 9 | 进程的概念 10 | --------------------------------- 11 | 12 | 导语中提到了,进程就是运行的程序。既然是程序,那么它就需要程序执行的一切资源,包括栈、寄存器等等。不同于用户线程,用户进程有着自己独立的用户栈和内核栈。但是无论如何寄存器是只有一套的,因此进程切换时对于寄存器的保存以及恢复是我们需要关心的问题。 13 | 14 | 为了研究进程的切换,我们先来搞懂用户进程长啥样,是如何运行的。不妨从上一节的 run_all_app 函数开始研究: 15 | 16 | .. code-block:: c 17 | 18 | int run_all_app() 19 | { 20 | for (int i = 0; i < app_num; ++i) { 21 | struct proc *p = allocproc(); 22 | struct trapframe *trapframe = p->trapframe; 23 | load_app(i, app_info_ptr); 24 | uint64 entry = BASE_ADDRESS + i * MAX_APP_SIZE; 25 | trapframe->epc = entry; 26 | trapframe->sp = (uint64)p->ustack + USER_STACK_SIZE; 27 | p->state = RUNNABLE; 28 | } 29 | return 0; 30 | } 31 | 32 | 首先介绍 struct proc 的定义。本章中新增的proc.h定义了我们OS的进程的PCB(进程管理块,和进程一一对应。它包含了进程几乎所有的信息)结构体; 33 | 34 | .. code-block:: C 35 | :linenos: 36 | 37 | // os/proc.h 38 | 39 | struct proc { 40 | enum procstate state; // 进程状态 41 | int pid; // 进程ID 42 | uint64 ustack; // 进程用户栈虚拟地址(用户页表) 43 | uint64 kstack; // 进程内核栈虚拟地址(内核页表) 44 | struct trapframe *trapframe; // 进程中断帧 45 | struct context context; // 用于保存进程内核态的寄存器信息,进程切换时使用 46 | }; 47 | 48 | enum procstate { 49 | UNUSED, // 未初始化 50 | USED, // 基本初始化,未加载用户程序 51 | SLEEPING, // 休眠状态(未使用,留待后续拓展) 52 | RUNNABLE, // 可运行 53 | RUNNING, // 当前正在运行 54 | ZOMBIE, // 已经 exit 55 | }; 56 | 57 | 可以看到每一个进程的PCB都保存了它当前的状态以及它的PID(每个进程的PID不同)。同时记录了其用户栈和内核栈的起始地址。trapframe和context在异常中断的切换以及进程之间的切换起到了保存的重要作用。 58 | 59 | 进程的状态是大家比较熟悉的问题了。OS课程上将进程的状态分为创建、就绪、执行、等待以及结束5大阶段(未来还会有挂起)。在我们的OS之中对状态的分类略有不同。我们一般用RUNNABLE代表就绪的进程,RUNNING代表正在执行的进程,UNUSED代表池中未分配或已经结束的进程,USED代表已经分配好但是还未加载完毕的进程。 60 | 61 | 进程的基本管理 62 | --------------------------------- 63 | 64 | 在我们的OS之中,我们采用了非常朴素的进程池方式来存放进程: 65 | 66 | .. code-block:: C 67 | :linenos: 68 | 69 | // os/trap.c 70 | 71 | struct proc pool[NPROC]; // 全局进程池 72 | struct proc idle; // boot 进程 73 | struct proc* current_proc; // 指示当前进程 74 | 75 | // 由于还有没内存管理机制,静态分配一些进程资源 76 | char kstack[NPROC][PAGE_SIZE]; 77 | __attribute__((aligned(4096))) char ustack[NPROC][PAGE_SIZE]; 78 | __attribute__((aligned(4096))) char trapframe[NPROC][PAGE_SIZE]; 79 | 80 | 81 | 可以看到我们最多同时有 NPROC 个进程,每一个进程的用户栈、内核栈以及trapframe所需的空间已经预先分配好了。当然缺点是进程池空间有限,不过直到lab8 之前大家都无需担心这个问题。 82 | 83 | 这里的 idle 进程是我们的 boot 进程,是我们执行初始化的进程,事实上,在引入用户进程前,idle 是唯一一个进程。比较重要的是 current_proc,它代表着当前正在执行的进程。因此这个变量在进程切换时也需要维护来保证其正确性。活用此变量能大大方便我们的编程。 84 | 85 | 进程模块初始化函数如下: 86 | 87 | .. code-block:: C 88 | 89 | // kernel/trap.c 90 | 91 | void procinit() 92 | { 93 | struct proc *p; 94 | for(p = pool; p < &pool[NPROC]; p++) { 95 | p->state = UNUSED; 96 | p->kstack = (uint64)kstack[p - pool]; 97 | p->ustack = (uint64)ustack[p - pool]; 98 | p->trapframe = (struct trapframe*)trapframe[p - pool]; 99 | } 100 | idle.kstack = (uint64)boot_stack_top; 101 | idle.pid = 0; 102 | } 103 | 104 | 进程的分配 105 | --------------------------------- 106 | 107 | 回到 run_all_app 函数,可以注意到首每个用户进程都被分配了一个 proc 结构,通过 alloc_proc 函数。进程的分配实际上本质就是从进程池中挑选一个还未使用(状态为UNUSED)的位置分配给进程。具体代码如下: 108 | 109 | .. code-block:: C 110 | :linenos: 111 | 112 | // os/proc.c 113 | 114 | // Look in the process table for an UNUSED proc. 115 | // If found, initialize state required to run in the kernel. 116 | // If there are no free procs, or a memory allocation fails, return 0. 117 | struct proc *allocproc() 118 | { 119 | struct proc *p; 120 | for (p = pool; p < &pool[NPROC]; p++) { 121 | if (p->state == UNUSED) { 122 | goto found; 123 | } 124 | } 125 | return 0; 126 | 127 | found: 128 | p->pid = allocpid(); 129 | p->state = USED; 130 | memset(&p->context, 0, sizeof(p->context)); 131 | memset(p->trapframe, 0, PAGE_SIZE); 132 | memset((void *)p->kstack, 0, PAGE_SIZE); 133 | p->context.ra = (uint64)usertrapret; 134 | p->context.sp = p->kstack + PAGE_SIZE; 135 | return p; 136 | } 137 | 138 | 分配进程需要初始化其PID以及清空其栈空间,并设置 context 第一次运行的入口地址 usertrapret,使得进程能够从内核的S态返回U态并执行自己的代码。我们需要看看进程切换相关的东西了。 -------------------------------------------------------------------------------- /source/chapter3/3multiprogramming.rst: -------------------------------------------------------------------------------- 1 | 多道程序与协作式调度 2 | ========================================= 3 | 4 | 5 | 本节导读 6 | -------------------------- 7 | 上一节我们已经介绍了任务管理的一些基本结构,这一节我们主要介绍进程切换相关的具体实现。 8 | 9 | 多道程序背景与 yield 系统调用 10 | ------------------------------------------------------------------------- 11 | 12 | 还记得第二章中介绍的批处理系统的设计初衷吗?它是注意到 CPU 并没有一直在执行应用程序,在一个应用程序运行结束直到下一个应用程序开始运行的这段时间,可能需要操作员取出上一个程序的执行结果并手动进行程序卡片的替换,这段空档期对于宝贵的 CPU 计算资源是一种巨大的浪费。于是批处理系统横空出世,它可以自动连续完成应用的加载和运行,并将一些本不需要 CPU 完成的简单任务交给廉价的外围设备,从而让 CPU 能够更加专注于计算任务本身,大大提高了 CPU 的利用率。 13 | 14 | .. _term-input-output: 15 | 16 | 尽管 CPU 一直在跑应用了,但是其利用率仍有上升的空间。随着应用需求的不断复杂,有的时候会在内核的监督下访问一些外设,它们也是计算机系统的另一个非常重要的组成部分,即 **输入/输出** (I/O, Input/Output) 。CPU 会将请求和一些附加的参数写入外设,待外设处理完毕之后, CPU 便可以从外设读到请求的处理结果。比如在从作为外部存储的磁盘上读取数据的时候,CPU 将要读取的扇区的编号以及读到的数据放到的物理地址传给磁盘,在磁盘对请求进行调度并完成数据拷贝之后,就能在物理内存中看到要读取的数据。 17 | 18 | 在一个应用对外设发出了请求之后,它不能立即向下执行,而是要等待外设将请求处理完毕并拿到完整的处理结果之后才能继续。那么如何知道外设是否已经完成了请求呢?通常外设会提供一个可读的寄存器记录它目前的工作状态,于是 CPU 需要不断原地循环读取它直到它的结果显示设备已经将请求处理完毕了,才能向下执行。然而,外设的计算速度和 CPU 相比可能慢了几个数量级,这就导致 CPU 有大量时间浪费在等待外设这件事情上,这段时间它几乎没有做任何事情,也在一定程度上造成了 CPU 的利用率不够理想。 19 | 20 | 我们暂时考虑 CPU 只能 *单方面* 通过读取外设提供的寄存器来获取外设请求处理的状态。多道程序的思想在于:内核同时管理多个应用。如果外设处理的时间足够长,那我们可以先进行任务切换去执行其他应用,在某次切换回来之后,应用再次读取设备寄存器,发现请求已经处理完毕了,那么就可以用拿到的完整的数据继续向下执行了。这样的话,只要同时存在的应用足够多,就能保证 CPU 不必浪费时间在等待外设上,而是几乎一直在进行计算。这种任务切换,是通过应用进行一个名为 ``sys_yield`` 的系统调用来实现的,这意味着它主动交出 CPU 的使用权给其他应用。 21 | 22 | 这正是本节标题的后半部分“协作式”的含义。一个应用会持续运行下去,直到它主动调用 ``sys_yield`` 来交出 CPU 使用权。内核将很大的权力下放到应用,让所有的应用互相协作来最终达成最大化 CPU 利用率,充分利用计算资源这一终极目标。在计算机发展的早期,由于应用基本上都是一些简单的计算任务,且程序员都比较遵守规则,因此内核可以信赖应用,这样协作式的制度是没有问题的。 23 | 24 | .. image:: multiprogramming.png 25 | 26 | 上图描述了一种多道程序执行的典型情况。其中横轴为时间线,纵轴为正在执行的实体。开始时,某个应用(蓝色)向外设提交了一个请求,随即可以看到对应的外设(紫色)开始工作。但是它要工作相当长的一段时间,因此应用(蓝色)不会去等待它结束而是会调用 ``sys_yield`` 主动交出 CPU 使用权来切换到另一个应用(绿色)。另一个应用(绿色)在执行了一段时间之后调用了 ``sys_yield`` ,此时内核决定让应用(蓝色)继续执行。它检查了一下外设的工作状态,发现请求尚未处理完,于是再次调用 ``sys_yield`` 。然后另一个应用(绿色)执行了一段时间之后 ``sys_yield`` 再次切换回这个应用(蓝色),这次的不同是它发现外设已经处理完请求了,于是它终于可以向下执行了。 27 | 28 | 上面我们是通过“避免无谓的外设等待来提高 CPU 利用率”这一切入点来引入 ``sys_yield`` 。但其实调用 ``sys_yield`` 不一定与外设有关。随着内核功能的逐渐复杂,我们还会遇到很多其他类型的需要等待其完成才能继续向下执行的事件,我们都可以立即调用 ``sys_yield`` 来避免等待过程造成的浪费。 29 | 30 | .. note:: 31 | 32 | **sys_yield 的缺点** 33 | 34 | 请读者思考一下, ``sys_yield`` 存在哪些缺点? 35 | 36 | 当应用调用它主动交出 CPU 使用权之后,它下一次再被允许使用 CPU 的时间点与内核的调度策略与当前的总体应用执行情况有关,很有可能远远迟于该应用等待的事件(如外设处理完请求)达成的时间点。这就会造成该应用的响应延迟不稳定,有可能极高。比如,设想一下,敲击键盘之后隔了数分钟之后才能在屏幕上看到字符,这已经超出了人类所能忍受的范畴。 37 | 38 | 但也请不要担心,我们后面会有更加优雅的解决方案。 39 | 40 | 我们给出 ``sys_yield`` 的标准接口: 41 | 42 | .. code-block:: c 43 | :caption: 第三章新增系统调用(一) 44 | 45 | /// 功能:应用主动交出 CPU 所有权并切换到其他应用。 46 | /// 返回值:总是返回 0。 47 | /// syscall ID:124 48 | int sys_yield(); 49 | 50 | 然后是用户库对应的实现和封装: 51 | 52 | .. code-block:: c 53 | 54 | // user/lib/syscall.c 55 | 56 | int sched_yield() 57 | { 58 | return syscall(SYS_sched_yield); 59 | } 60 | 61 | 接下来我们介绍内核应如何实现该系统调用。 62 | 63 | 64 | 进程的切换 65 | --------------------------------- 66 | 67 | 下面我们介绍本章的最最最重要的进程切换(调度)问题。 68 | 69 | 进程的切换? 70 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 71 | 72 | 说到切换,大家肯定对第二章中异常产生后,从U态切换到S态的流程历历在目。实际上,进程的切换和它十分类似。大家可以类比一下,第二章我们是从进程1--->内核态处理异常--->进程1。那我们完全可以把整个流程转换为进程1-->内核切换-->进程2-->切换-->进程1来实现执行流的切换,并且保证中途的保存和恢复不出错呀!当然这么做会比较复杂,我们的处理方式更加复古一点,但是思路基本是一样的。回顾一下进程的PCB结构体中两个用于切换的结构体成员: 73 | 74 | .. code-block:: C 75 | :linenos: 76 | 77 | struct trapframe *trapframe; 78 | struct context context; 79 | 80 | trapframe大家在第二章已经和它打过交到了。那么context这个结构体又记录了什么呢? 81 | 82 | .. code-block:: C 83 | :linenos: 84 | 85 | // os/trap.h 86 | 87 | // Saved registers for kernel context switches. 88 | struct context { 89 | uint64 ra; 90 | uint64 sp; 91 | // callee-saved 92 | uint64 s0; 93 | uint64 s1; 94 | uint64 s2; 95 | uint64 s3; 96 | uint64 s4; 97 | uint64 s5; 98 | uint64 s6; 99 | uint64 s7; 100 | uint64 s8; 101 | uint64 s9; 102 | uint64 s10; 103 | uint64 s11; 104 | }; 105 | 106 | 它相比trapframe,只记录了寄存器的信息。聪明的你可能已经发现它们都是被调用者保存的寄存器。在切换的核心函数swtch(注意拼写)之中,就是对这个结构体进行了操作: 107 | 108 | .. code-block:: riscv 109 | :linenos: 110 | 111 | # Context switch 112 | # 113 | # void swtch(struct context *old, struct context *new); 114 | # 115 | # Save current registers in old. Load from new. 116 | 117 | .globl swtch 118 | 119 | # a0 = &old_context, a1 = &new_context 120 | 121 | swtch: 122 | sd ra, 0(a0) # save `ra` 123 | sd sp, 8(a0) # save `sp` 124 | sd s0, 16(a0) 125 | sd s1, 24(a0) 126 | sd s2, 32(a0) 127 | sd s3, 40(a0) 128 | sd s4, 48(a0) 129 | sd s5, 56(a0) 130 | sd s6, 64(a0) 131 | sd s7, 72(a0) 132 | sd s8, 80(a0) 133 | sd s9, 88(a0) 134 | sd s10, 96(a0) 135 | sd s11, 104(a0) 136 | 137 | ld ra, 0(a1) # restore `ra` 138 | ld sp, 8(a1) # restore `sp` 139 | ld s0, 16(a1) 140 | ld s1, 24(a1) 141 | ld s2, 32(a1) 142 | ld s3, 40(a1) 143 | ld s4, 48(a1) 144 | ld s5, 56(a1) 145 | ld s6, 64(a1) 146 | ld s7, 72(a1) 147 | ld s8, 80(a1) 148 | ld s9, 88(a1) 149 | ld s10, 96(a1) 150 | ld s11, 104(a1) 151 | 152 | ret # return to new `ra` 153 | 154 | 为什么只切换这些寄存器就能实现一个切换的效果呢?这是因为执行了swtch切换状态之后,切换的目标进程恢复了保存在context之中的寄存器,并且sp寄存器也指向了它自己栈的位置,ra指向自己测例代码的位置而不是之前函数的位置,这已经足够其从切换出去的位置继续执行了(切换的过程可以视为一次函数调用)。因为真正切换swtch都在内核态发生,也无需记录更多的数据。 155 | 156 | 总结一下,swtch函数干了这些事情: 157 | - 执行流:通过切换 ra 158 | - 堆栈:通过切换 sp 159 | - 寄存器: 通过保存和恢复被调用者保存寄存器。调用者保存寄存器由编译器生成的代码负责保存和恢复。 160 | 161 | 一旦你理解了上述的过程,那么本章剩余内容就会十分简单~~ 162 | 163 | 下面介绍进程切换的具体细节。 164 | 165 | idle进程与scheduler 166 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 167 | 168 | 大家可能注意到proc.c文件中除了current_proc还记录了一个idle_proc。这个进程是干什么的呢?实际上,idle 进程是第一个进程(boot进程),也是唯一一个永远会存在的进程,它还有一个大家更熟悉的面孔,它就是 os 的 main 函数。 169 | 170 | 是时候从头开始梳理从机器 boot 到多个用户进程相互切换到底发生了什么了。 171 | 172 | .. code-block:: C 173 | :linenos: 174 | 175 | void main() { 176 | clean_bss(); // 清空 bss 段 177 | trapinit(); // 开启中断 178 | batchinit(); // 初始化 app_info_ptr 指针 179 | procinit(); // 初始化线程池 180 | // timerinit(); // 开启时钟中断,现在还没有 181 | run_all_app(); // 加载所有用户程序 182 | scheduler(); // 开始调度 183 | } 184 | 185 | 可以看到,在main函数完成了一系列的初始化,并且执行了run_all_app加载完了所有测例之后。它就进入了scheduler调度函数。这个函数就完成了一系列的调度工作: 186 | 187 | .. code-block:: C 188 | :linenos: 189 | 190 | void 191 | scheduler(void) 192 | { 193 | struct proc *p; 194 | 195 | for(;;){ 196 | for(p = pool; p < &pool[NPROC]; p++) { 197 | if(p->state == RUNNABLE) { 198 | p->state = RUNNING; 199 | current_proc = p; 200 | swtch(&idle.context, &p->context); 201 | } 202 | } 203 | } 204 | } 205 | 206 | 可以看到一旦main进入调度状态就进入一个死循环再也回不去了。。但它也没必要回去,它现在活着的意义就是为了进行进程的调度。在循环中每一次idle进程都会遍历整个进程池来寻找RUNNABLE(就绪)状态的进程并执行swtch函数切换到它。我们这里的scheduler函数就是最普通的调度函数,完全没有考虑优先度以及复杂度。 207 | 208 | 这里大家要思考一下,这个函数写对了吗?它真的满足我们每次执行遍历一次的要求,而不是写成了每次都从第0个进程开始遍历查找吗? 209 | 210 | 211 | yield函数的实现 212 | ------------------------------------------ 213 | 214 | yield 函数具体实现如下: 215 | 216 | .. code-block:: C 217 | :linenos: 218 | 219 | // os/proc.c 220 | 221 | // Give up the CPU for one scheduling round. 222 | void yield(void) 223 | { 224 | current_proc->state = RUNNABLE; 225 | sched(); 226 | } 227 | 228 | void sched(void) 229 | { 230 | struct proc *p = curr_proc(); 231 | swtch(&p->context, &idle.context); 232 | } 233 | 234 | 它本质就是主动放弃执行,并把context移交给负责scheduler进程的idle进程。那这个时候 idle 进程在干什么? 235 | 236 | `idle` 线程死循环在了一件事情上:寻找一个 `RUNNABLE` 的进程,然后切换到它开始执行。当这个进程调用 `sched` 后,执行流会回到 `idle` 线程,然后继续开始寻找,如此往复。直到所有进程执行完毕,在 sys_exit 系统调用中有统计计数,一旦 exit 的进程达到用户程序数量就关机。 237 | 238 | 也就是说,所有进程间切换都需要通过 `idle` 中转一下。那么可不可以一步到位呢?答案是肯定的,其实 [rust版代码lab3](https://github.com/rcore-os/rCore-Tutorial-v3) 就是采取这种实现:在一个进程退出时,直接寻找下一个就绪进程,然后直接切换过去,没有 idle 的中转。两种实现都是可行的。 239 | 240 | 在了解这些之后,我们就可以实现协作式调度了,主要是 `sys_yeild` 系统调用,其实现十分简单,请同学们自行查看 `kernel/syscall.c`。 -------------------------------------------------------------------------------- /source/chapter3/5exercise.rst: -------------------------------------------------------------------------------- 1 | chapter3练习 2 | ======================================= 3 | 4 | - 本节难度:编程试水 5 | 6 | 本章任务 7 | ----------------------------------------------------- 8 | - 注意本节任务最终对应一次 lab 提交。 9 | - 老规矩,先 `make test BASE=1` 看下啥情况。 10 | - 理解框架的多任务加载机制,了解此时用户和内核的大概内存布局。在此基础上,实现本章编程作业 sys_task_info。 11 | - 最终,完成实验报告并 push 你的 ch3 分支到远程仓库。push 代码后会自动执行 CI,代码给分以 CI 给分为准。 12 | 13 | 获取任务信息 14 | ++++++++++++++++++++++++++ 15 | 16 | ch3 中,我们的系统已经能够支持多个任务分时轮流运行,我们希望引入一个新的系统调用 ``sys_task_info`` 以获取任务的信息,定义如下: 17 | 18 | .. code-block:: C 19 | 20 | int sys_task_info(TaskInfo *ti); 21 | 22 | - syscall ID: 410 23 | - 查询当前正在执行的任务信息,任务信息包括任务控制块相关信息(任务状态)、任务使用的系统调用次数、任务总运行时长。 24 | 25 | .. code-block:: C 26 | 27 | struct TaskInfo { 28 | TaskStatus status; 29 | unsigned int syscall_times[MAX_SYSCALL_NUM]; 30 | int time; 31 | }; 32 | 33 | - 参数: 34 | - ti: 待查询任务信息 35 | - 返回值:执行成功返回0,错误返回-1 36 | - 说明: 37 | - 相关结构已在框架中给出,只需添加逻辑实现功能需求即可。 38 | - 在我们的实验中,系统调用号一定小于 500,所以直接使用一个长为 ``MAX_SYSCALL_NUM=500`` 的数组做桶计数。 39 | - 我们约定调用 sys_task_info 时对本次系统调用也进行计数,因此返回的 syscall_times 中对 sys_task_info 的计数一定不会是 0。 40 | - 简单起见运行时间 time 返回系统调用时刻距离任务首次被调度时刻的时长即可;一种更有意义但是难度稍微高一些的做法是返回任务处于 Running 状态的运行时长:包含用户态时间和内核态时间,不包含调度到其他任务的时间。你可以思考下后者如何实现,但是为了通过测例建议使用前者(即考虑 real time)。 41 | - 我们约定 time 的单位是 ms,为了得到时间可以使用 `get_cycle` 配合 CPU_FREQ,参考系统调用中 `sys_gettimeofday` 的实现或许会对你有所启发。 42 | - 由于查询的是当前任务的状态,因此 TaskStatus 一定是 Running。(助教起初想设计根据任务 id 查询,但是既不好定义任务 id 也不好写测例,遂放弃 QAQ) 43 | - 提示: 44 | - 大胆修改已有框架!除了配置文件,你几乎可以随意修改已有框架的内容。 45 | - 程序运行时间可以通过调用 ``get_time()`` 获取。 46 | - 系统调用次数可以考虑在进入内核态系统调用异常处理函数之后,进入具体系统调用函数之前维护。 47 | - 阅读 proc 的实现,思考如何维护内核控制块信息(可以在控制块可变部分加入其他需要的信息)。 48 | 49 | 实验要求 50 | +++++++++++++++++++++++++++++++++++++++++ 51 | 52 | - 完成分支: ch3。 53 | 54 | - 实验目录要求 55 | 56 | .. code-block:: 57 | 58 | ├── os(内核实现) 59 | │   └── ... 60 | ├── reports (不是 report) 61 | │   ├── lab1.md/pdf 62 | │   └── ... 63 | ├── ... 64 | 65 | 66 | - 通过所有测例: 67 | 68 | CI 使用的测例与本地相同,测试中,user 文件夹及其它与构建相关的文件将被替换,请不要试图依靠硬编码通过测试。 69 | 70 | .. note:: 71 | 72 | 你的实现只需且必须通过测例,建议读者感到困惑时先检查测例。 73 | 74 | .. ch3问答作业:: 75 | 76 | 问答作业 77 | -------------------------------------------- 78 | 79 | 1. 正确进入 U 态后,程序的特征还应有:使用 S 态特权指令,访问 S 态寄存器后会报错。请同学们可以自行测试这些内容(参考 `前三个测例 `_ ,描述程序出错行为,同时注意注明你使用的 sbi 及其版本。 80 | 81 | 2. 请结合用例理解 `trampoline.S `_ 中两个函数 `userret` 和 `uservec` 的作用,并回答如下几个问题: 82 | 83 | 1. L79: 刚进入 `userret` 时,`a0`、`a1` 分别代表了什么值。 84 | 85 | 2. L87-L88: `sfence` 指令有何作用?为什么要执行该指令,当前章节中,删掉该指令会导致错误吗? 86 | 87 | .. code-block:: assembly 88 | 89 | csrw satp, a1 90 | sfence.vma zero, zero 91 | 92 | 3. L96-L125: 为何注释中说要除去 `a0`?哪一个地址代表 `a0`?现在 `a0` 的值存在何处? 93 | 94 | .. code-block:: assembly 95 | 96 | # restore all but a0 from TRAPFRAME 97 | ld ra, 40(a0) 98 | ld sp, 48(a0) 99 | ld t5, 272(a0) 100 | ld t6, 280(a0) 101 | 102 | 4. `userret`:中发生状态切换在哪一条指令?为何执行之后会进入用户态? 103 | 104 | 5. L29: 执行之后,a0 和 sscratch 中各是什么值,为什么? 105 | 106 | .. code-block:: assembly 107 | 108 | csrrw a0, sscratch, a0 109 | 110 | 6. L32-L61: 从 trapframe 第几项开始保存?为什么?是否从该项开始保存了所有的值,如果不是,为什么? 111 | 112 | .. code-block:: assembly 113 | 114 | sd ra, 40(a0) 115 | sd sp, 48(a0) 116 | ... 117 | sd t5, 272(a0) 118 | sd t6, 280(a0) 119 | 120 | 7. 进入 S 态是哪一条指令发生的? 121 | 122 | 8. L75-L76: `ld t0, 16(a0)` 执行之后,`t0`中的值是什么,解释该值的由来? 123 | 124 | .. code-block:: assembly 125 | 126 | ld t0, 16(a0) 127 | jr t0 128 | 129 | 130 | .. ch3报告要求:: 131 | 132 | 报告要求 133 | ------------------------------- 134 | - pdf 格式,CI 网站提交,注明姓名学号。 135 | - 注意目录要求,报告命名 ``lab1.md`` 或 ``lab1.pdf``,位于 ``reports`` 目录下。命名错误视作没有提交。后续实验同理。 136 | - 简单总结本次实验你新添加的代码。 137 | - 完成问答问题。 138 | 139 | - [可选,不占分]你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。 140 | 141 | .. warning:: 142 | 143 | 请勿抄袭,报告会进行抽样查重! 144 | -------------------------------------------------------------------------------- /source/chapter3/fsm-coop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter3/fsm-coop.png -------------------------------------------------------------------------------- /source/chapter3/index.rst: -------------------------------------------------------------------------------- 1 | .. _link-chapter3: 2 | 3 | 第三章:多道程序与分时多任务 4 | ============================================== 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | 0intro 10 | 1multi-loader 11 | 2proc-basic 12 | 3multiprogramming 13 | 4time-sharing-system 14 | 5exercise 15 | -------------------------------------------------------------------------------- /source/chapter3/multiprogramming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter3/multiprogramming.png -------------------------------------------------------------------------------- /source/chapter3/switch-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter3/switch-1.png -------------------------------------------------------------------------------- /source/chapter3/switch-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter3/switch-2.png -------------------------------------------------------------------------------- /source/chapter3/task_context.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter3/task_context.png -------------------------------------------------------------------------------- /source/chapter4/0intro.rst: -------------------------------------------------------------------------------- 1 | 引言 2 | ============================== 3 | 4 | 本章导读 5 | ------------------------------- 6 | 7 | .. 8 | chyyuu:有一个ascii图,画出我们做的OS。 9 | 10 | 本章展现了操作系统一系列功能: 11 | 12 | - 通过动态内存分配,提高了应用程序对内存的动态使用效率 13 | - 通过页表的虚实内存映射机制,简化了编译器对应用的地址空间设置 14 | - 通过页表的虚实内存映射机制,加强了应用之间,应用与内核之间的内存隔离,增强了系统安全 15 | - 通过页表的虚实内存映射机制,可以实现空分复用(提出,但没有实现) 16 | 17 | .. _term-illusion: 18 | .. _term-time-division-multiplexing: 19 | .. _term-transparent: 20 | 21 | 上一章,我们分别实现了多道程序和分时多任务系统,它们的核心机制都是任务切换。由于多道程序和分时多任务系统的设计初衷不同,它们在任务切换的时机和策略也不同。有趣的一点是,任务切换机制对于应用是完全 **透明** (Transparent) 的,应用可以不对内核实现该机制的策略做任何假定(除非要进行某些针对性优化),甚至可以完全不知道这机制的存在。 22 | 23 | 在大多数应用(也就是应用开发者)的视角中,它们会独占一整个 CPU 和特定(连续或不连续)的内存空间。当然,通过上一章的学习,我们知道在现代操作系统中,出于公平性的考虑,我们极少会让独占CPU这种情况发生。所以应用自认为的独占CPU只是内核想让应用看到的一种 **幻象** (Illusion) ,而 CPU 计算资源被 **时分复用** (TDM, Time-Division Multiplexing) 的实质被内核通过恰当的抽象隐藏了起来,对应用不可见。 24 | 25 | 与之相对,我们目前还没有对内存管理功能进行有效的管理,仅仅是把程序放到某处的物理内存中。在内存访问方面,所有的应用都直接通过物理地址访问物理内存,这使得应用开发者需要了解繁琐的物理地址空间布局,访问内存也很不方便。在上一章中,出于任务切换的需要,所有的应用都在初始化阶段被加载到内存中并同时驻留下去直到它们全部运行结束。而且,所有的应用都直接通过物理地址访问物理内存。这会带来以下问题: 26 | 27 | - 首先,内核提供给应用的内存访问接口不够透明,也不好用。由于应用直接访问物理内存,这需要它在构建的时候就需要规划自己需要被加载到哪个地址运行。为了避免冲突可能还需要应用的开发者们对此进行协商,这显然是一件在今天看来不可理喻且极端麻烦的事情。 28 | - 其次,内核并没有对应用的访存行为进行任何保护措施,每个应用都有整块物理内存的读写权力。即使应用被限制在 U 特权级下运行,它还是能够造成很多麻烦:比如它可以读写其他应用的数据来窃取信息或者破坏它的正常运行;甚至它还可以修改内核的代码段来替换掉原本的 ``trap_handler`` 来挟持内核执行恶意代码。总之,这造成系统既不安全、也不稳定。 29 | - 再次,目前应用的内存使用空间在其运行前已经限定死了,内核不能灵活地给应用程序提供的运行时动态可用内存空间。比如一个应用结束后,这个应用所占的空间就被释放了,但这块空间无法动态地给其它还在运行的应用使用。 30 | 31 | 因此,为了防止应用胡作非为,本章将更好的管理物理内存,并提供给应用一个抽象出来的更加透明易用、也更加安全的访存接口,这就是基于分页机制的虚拟内存。站在应用程序运行的角度看,就是存在一个从“0”地址开始的非常大的可读/可写/可执行的地址空间(Address Space)。 32 | 33 | 实现地址空间的第一步就是实现分页机制,建立好虚拟内存和物理内存的页映射关系。此过程涉及硬件细节,不同的地址映射关系组合,相对比较复杂。总体而言,我们需要思考如下问题: 34 | 35 | - 硬件中物理内存的范围是什么? 36 | - 哪些物理内存空间需要建立页映射关系? 37 | - 如何建立页表使能分页机制? 38 | - 如何确保OS能够在分页机制使能前后的不同时间段中都能正常寻址和执行代码? 39 | - 页目录表(一级)的起始地址设置在哪里? 40 | - 二级/三级等页表的起始地址设置在哪里,需要多大空间? 41 | - 如何设置页目录表项的内容? 42 | - 如何设置其它页表项的内容? 43 | - 如果要让每个任务有自己的地址空间,那每个任务是否要有自己的页表? 44 | - 代表应用程序的任务和操作系统需要有各自的页表吗? 45 | - 在有了页表之后,任务和操作系统之间应该如何传递数据? 46 | 47 | 如果能解决上述问题,我们就能设计实现具有超强防护能力的侏罗纪“头甲龙”操作系统。并可更好地理解地址空间,虚拟地址等操作系统的抽象概念与操作系统的虚存具体实现之间的联系。 48 | 49 | .. 50 | chyyuu:在哪里讲解虚存的设计与实现??? 51 | 52 | github 多仓库使用讲解 53 | ----------------------- 54 | 55 | 针对有多个源的情况,可以使用 ``git remote add origin url`` 把另外一个远程仓库设置为 remote 。这里的 url 是对应远程仓库的链接。 56 | 57 | 使用 ``git remote -v`` 可以查看本地已经关联的仓库。使用 ``git remote rm origin`` 可以删除远程库。 58 | 59 | 注意, **origin** 是我们给一个远程仓库设置的别名,因此这个是可以任取的,而不是一定要使用默认的这个 origin。 60 | 建议大家给不同的远程仓库起不同的名字便于在 push, pull 等操作之中区分对应的仓库。 61 | 62 | 之后,对于 git push, git fetch,git pull 等命令之中大家常用的 origin,就需要按需求改为对应的远程仓库别名。 63 | 我们的实验在 github 和 gitlab 之上都有仓库。如果大家有去拉 github 仓库的需求,可以参考如上设置 github 新的远程仓库。 64 | 65 | .. code-block:: console 66 | 67 | $ cd uCore-Tutorial-Code-2022S 68 | # 你可以将 upstream 改为你喜欢的名字 69 | $ git remote add upstream https://github.com/LearningOS/uCore-Tutorial-Code-2022S.git 70 | # 更新仓库信息 71 | $ git fetch upstream 72 | # 查看已添加的远程仓库;应该能看到已有一个 origin 和新添加的 upstream 仓库 73 | $ git remote -v 74 | # 根据需求选择以下一种操作即可 75 | # 在本地新建一个与远程仓库对应的分支: 76 | $ git checkout -b ch4 upstream/ch4 77 | # 本地已有分支,从远程仓库更新: 78 | $ git checkout ch4 79 | $ git merge upstream/ch4 80 | # 将更新推送到自己的远程仓库 81 | $ git push origin ch4 82 | 83 | 实践体验 84 | ----------------------- 85 | 86 | 本章的应用和上一章相同,只不过由于内核提供给应用的访存接口被替换,应用的构建方式发生了变化,这方面在下面会深入介绍。 87 | 因此应用运行起来的效果与上一章是一致的。 88 | 89 | 获取本章代码: 90 | 91 | .. code-block:: console 92 | 93 | $ git checkout ch4 94 | 95 | 在 qemu 模拟器上运行本章代码: 96 | 97 | .. code-block:: console 98 | 99 | $ make test BASE=1 100 | 101 | 102 | 本章代码树 103 | ----------------------------------------------------- 104 | 105 | .. code-block:: bash 106 | 107 | :linenos: 108 | :emphasize-lines: 56 109 | 110 | . 111 | ├── bootloader 112 | │ └── rustsbi-qemu.bin 113 | ├── LICENSE 114 | ├── Makefile 115 | ├── os 116 | │ ├── console.c 117 | │ ├── console.h 118 | │ ├── const.h 119 | │ ├── defs.h 120 | │ ├── entry.S 121 | │ ├── kalloc.c 122 | │ ├── kalloc.h 123 | │ ├── kernel.ld 124 | │ ├── kernelld.py 125 | │ ├── loader.c 126 | │ ├── loader.h 127 | │ ├── log.h 128 | │ ├── main.c 129 | │ ├── pack.py 130 | │ ├── printf.c 131 | │ ├── printf.h 132 | │ ├── proc.c 133 | │ ├── proc.h 134 | │ ├── riscv.h 135 | │ ├── sbi.c 136 | │ ├── sbi.h 137 | │ ├── string.c 138 | │ ├── string.h 139 | │ ├── switch.S 140 | │ ├── syscall.c 141 | │ ├── syscall.h 142 | │ ├── syscall_ids.h 143 | │ ├── timer.c 144 | │ ├── timer.h 145 | │ ├── trampoline.S 146 | │ ├── trap.c 147 | │ ├── trap.h 148 | │ ├── types.h 149 | │ ├── vm.c 150 | │ └── vm.h 151 | ├── README.md 152 | ├── scripts 153 | │ ├── kernelld.py 154 | │ └── pack.py 155 | └── user 156 | 157 | 158 | 159 | 本章代码导读 160 | ----------------------------------------------------- 161 | 162 | 本章涉及的代码量相对多了起来。新增的代码主要是集中在页表的处理上的。由于课程整改,春季学期的同学们可能还没有上过计组,对页表的内容还不太熟悉。因此本章的内容可能需要同学们多多回顾OS课上对页表的讲解。同时本章也会介绍我们OS的Riscv-64指令集是如何设计页表,以及页表读取和修改的方式。 -------------------------------------------------------------------------------- /source/chapter4/1rust-dynamic-allocation.rst: -------------------------------------------------------------------------------- 1 | C 中的动态内存分配 2 | ======================================================== 3 | 4 | 5 | 本节导读 6 | -------------------------- 7 | 8 | 9 | 到目前为止,如果将我们的内核也看成一个应用,那么其中所有的变量都是被静态分配在内存中的,这样在对空闲内存的使用方面缺少灵活性。我们希望能在操作系统中提供动态申请和释放内存的能力,这样就可以加强操作系统对各种以内存为基础的资源分配与管理。 10 | 11 | 在应用程序的视角中,动态内存分配中的内存,其实就是操作系统管理的“堆 (Heap)”。但现在要实现操作系统,那么就需要操作系统自身能提供动态内存分配的能力。如果要实现动态内存分配的能力,需要操作系统需要有如下功能: 12 | 13 | - 初始时能提供一块大内存空间作为初始的“堆”。在没有分页机制情况下,这块空间是物理内存空间,否则就是虚拟内存空间。 14 | - 提供在堆上分配一块内存的函数接口。这样函数调用方就能够得到一块地址连续的空闲内存块进行读写。 15 | - 提供释放内存的函数接口。能够回收内存,以备后续的内存分配请求。 16 | - 提供空闲空间管理的连续内存分配算法。能够有效地管理空闲快,这样就能够动态地维护一系列空闲和已分配的内存块。 17 | - (可选)提供建立在堆上的数据结构和操作。有了上述基本的内存分配与释放函数接口,就可以实现类似动态数组,动态字典等空间灵活可变的堆数据结构,提高编程的灵活性。 18 | 19 | 在使用C++语言的过程中,大家其实对new/delete的使用方法已经烂熟于心了。在C中,对动态内存的申请是采用如下的函数实现的: 20 | 21 | .. code-block:: c 22 | 23 | void* malloc (size_t size); 24 | void free (void* ptr); 25 | 26 | 其中,``malloc`` 的作用是从堆中分配一块大小为 ``size`` 字节的空间,并返回一个指向它的指针。而后续不用的时候,将这个 27 | 指针传给 ``free`` 即可在堆中回收这块空间。我们通过返回的指针变量来间接访问堆上的空间,而无法直接进行 28 | 访问。事实上,我们在程序中能够 *直接* 看到的变量都是被静态分配在栈或者全局数据段上的,它们大小在编译期已知,比如这里 29 | 一个指针类型的大小就可以等于计算机可寻址空间的位宽。这样的它们却可以作为背后一块大小在编译期无法确定的空间的代表,这是一件非常有趣的 30 | 事情。 31 | 32 | 对于同一个页的地址而言它对应的物理内存时连续的。但是,连续的虚拟地址空间不一定对应着连续的物理地址空间,因此我们需要一个数据结构来存储哪些物理内存是可用的。对于这种给不连续的情况,我们采用了链表的数据结构,将空闲的每个PAGE大小的物理内存空间作为listnode来进行内存的管理。这些新增的代码在kalloc.c之中。 33 | 34 | kalloc之中的动态内存分配 35 | ---------------------------------------------- 36 | 37 | 我们采用链表结构记录空闲的物理地址。因此当应用程序申请一段动态内存的时候,只需要把链表头所指向地址拿出即可。 38 | 39 | .. code-block:: c 40 | 41 | // os/kalloc.c 42 | struct linklist { 43 | struct linklist *next; 44 | }; 45 | 46 | struct { 47 | struct linklist *freelist; 48 | } kmem; 49 | 50 | 注意,我们的管理仅仅在页这个粒度进行,所以所有的地址必须是 PAGE_SIZE 对齐的。 51 | 52 | .. code-block:: c 53 | 54 | // os/kalloc.c: 页面分配 55 | void * 56 | kalloc(void) 57 | { 58 | struct linklist *l; 59 | l = kmem.freelist; 60 | kmem.freelist = l->next; 61 | return (void*)l; 62 | } 63 | 64 | // os/kalloc.c: 页面释放 65 | void * 66 | kfree(void *pa) 67 | { 68 | struct linklist *l; 69 | l = (struct linklist*)pa; 70 | l->next = kmem.freelist; 71 | kmem.freelist = l; 72 | } 73 | 74 | 那么我们的内核有那些空闲内存需要管理呢?事实上,qemu 已经规定了内核需要管理的内存范围,可以参考这里,具体来说,需要软件管理的内存为 [0x80000000, 0x88000000),其中,rustsbi 使用了 [0x80000000, 0x80200000) 的范围,其余都是内核使用。来看看 kmem 的初始化 75 | 76 | .. code-block:: c 77 | 78 | // os/kalloc.c 79 | 80 | // ekernel 为链接脚本定义的内核代码结束地址,PHYSTOP = 0x88000000 81 | void 82 | kinit() 83 | { 84 | freerange(ekernel, (void*)PHYSTOP); 85 | } 86 | 87 | // kfree [pa_start, pa_end) 88 | void 89 | freerange(void *pa_start, void *pa_end) 90 | { 91 | char *p; 92 | p = (char*)PGROUNDUP((uint64)pa_start); 93 | for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) 94 | kfree(p); 95 | } 96 | 97 | 我们在main函数中会执行kinit,它会初始化从ekernel到PHYSTOP的所有物理地址作为空闲的物理地址。freerange中调用的kfree函数以页为单位向对应内存中填入垃圾数据(全1),并把初始化好的一个页作为新的空闲listnode插入到链表首部。 98 | 99 | 注意,C语言之中要求进行内存回收,也就是malloc以及free要成对出现。但是我们的OS中不强制要求这一点,也就是如果测例本身未在申请动态内存后显式地调用free来释放内存,OS无需帮助它释放内存。 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /source/chapter4/3sv39-implementation-1.rst: -------------------------------------------------------------------------------- 1 | SV39多级页表机制:内容介绍 2 | ======================================================== 3 | 4 | 5 | 本节导读 6 | -------------------------- 7 | 8 | 在上一小节中我们已经简单介绍了分页的内存管理策略,现在我们尝试在 RV64 架构提供的 SV39 分页机制的基础上完成内核中的软件对应实现。由于内容过多,我们将分成两个小节进行讲解。本节主要讲解在RV64架构下的虚拟地址与物理地址的访问属性(可读,可写,可执行等),组成结构(页号,帧号,偏移量等),访问的空间范围等;以及我们在OS中如何进行页表的处理。 9 | 10 | 11 | 虚拟地址和物理地址 12 | ------------------------------------------------------ 13 | 14 | 内存控制相关的CSR寄存器 15 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 16 | 17 | 默认情况下 MMU 未被使能,此时无论 CPU 位于哪个特权级,访存的地址都会作为一个物理地址交给对应的内存控制单元来直接 18 | 访问物理内存。我们可以通过修改 S 特权级的一个名为 ``satp`` 的 CSR 来启用分页模式,在这之后 S 和 U 特权级的访存 19 | 地址会被视为一个虚拟地址,它需要经过 MMU 的地址转换变为一个物理地址,再通过它来访问物理内存;而 M 特权级的访存地址,我们可设定是内存的物理地址。 20 | 21 | 22 | .. note:: 23 | 24 | M 特权级的访存地址被视为一个物理地址还是一个需要经历和 S/U 特权级相同的地址转换的虚拟地址取决于硬件配置,在这里我们不会进一步探讨。 25 | 26 | .. chyyuu M模式下,应该访问的是物理地址??? 27 | 28 | .. image:: satp.png 29 | :name: satp-layout 30 | 31 | 上图是 RV64 架构下 ``satp`` 的字段分布。当 ``MODE`` 设置为 0 的时候,代表所有访存都被视为物理地址;而设置为 8 32 | 的时候,SV39 分页机制被启用,所有 S/U 特权级的访存被视为一个 39 位的虚拟地址,它们需要先经过 MMU 的地址转换流程, 33 | 如果顺利的话,则会变成一个 56 位的物理地址来访问物理内存;否则则会触发异常,这体现了该机制的内存保护能力。 34 | 35 | 虚拟地址和物理地址都是字节地址,39 位的虚拟地址可以用来访问理论上最大 :math:`512\text{GiB}` 的地址空间, 36 | 而 56 位的物理地址在理论上甚至可以访问一块大小比这个地址空间的还高出几个数量级的物理内存。但是实际上无论是 37 | 虚拟地址还是物理地址,真正有意义、能够通过 MMU 的地址转换或是 CPU 内存控制单元的检查的地址仅占其中的很小 38 | 一部分,因此它们的理论容量上限在目前都没有实际意义。 39 | 40 | 41 | 地址格式与组成 42 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 43 | 44 | .. image:: sv39-va-pa.png 45 | 46 | .. _term-page-offset: 47 | 48 | 我们采用分页管理,单个页面的大小设置为 :math:`4\text{KiB}` ,每个虚拟页面和物理页帧都对齐到这个页面大小,也就是说 49 | 虚拟/物理地址区间 :math:`[0,4\text{KiB})` 为第 :math:`0` 个虚拟页面/物理页帧,而 50 | :math:`[4\text{KiB},8\text{KiB})` 为第 :math:`1` 个,以此类推。 :math:`4\text{KiB}` 需要用 12 位字节地址 51 | 来表示,因此虚拟地址和物理地址都被分成两部分:它们的低 12 位,即 :math:`[11:0]` 被称为 **页内偏移** 52 | (Page Offset) ,它描述一个地址指向的字节在它所在页面中的相对位置。而虚拟地址的高 27 位,即 :math:`[38:12]` 为 53 | 它的虚拟页号 VPN,同理物理地址的高 44 位,即 :math:`[55:12]` 为它的物理页号 PPN,页号可以用来定位一个虚拟/物理地址 54 | 属于哪一个虚拟页面/物理页帧。 55 | 56 | 地址转换是以页为单位进行的,在地址转换的前后地址的页内偏移部分不变。可以认为 MMU 只是从虚拟地址中取出 27 位虚拟页号, 57 | 在页表中查到其对应的物理页号(如果存在的话),最后将得到的44位的物理页号与虚拟地址的12位页内偏移依序拼接到一起就变成了56位的物理地址。 58 | 59 | .. _high-and-low-256gib: 60 | 61 | .. note:: 62 | 63 | **RV64 架构中虚拟地址为何只有 39 位?** 64 | 65 | 在 64 位架构上虚拟地址长度确实应该和位宽一致为 64 位,但是在启用 SV39 分页模式下,只有低 39 位是真正有意义的。 66 | SV39 分页模式规定 64 位虚拟地址的 :math:`[63:39]` 这 25 位必须和第 38 位相同,否则 MMU 会直接认定它是一个 67 | 不合法的虚拟地址。通过这个检查之后 MMU 再取出低 39 位尝试将其转化为一个 56 位的物理地址。 68 | 69 | 也就是说,所有 :math:`2^{64}` 个虚拟地址中,只有最低的 :math:`256\text{GiB}` (当第 38 位为 0 时) 70 | 以及最高的 :math:`256\text{GiB}` (当第 38 位为 1 时)是可能通过 MMU 检查的。当我们写软件代码的时候,一个 71 | 地址的位宽毋庸置疑就是 64 位,我们要清楚可用的只有最高和最低这两部分,尽管它们已经巨大的超乎想象了;而本节中 72 | 我们专注于介绍 MMU 的机制,强调 MMU 看到的真正用来地址转换的虚拟地址只有 39 位。 73 | 74 | 多级页表原理 75 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 76 | 77 | 页表的一种最简单的实现是线性表,也就是按照地址从低到高、输入的虚拟页号从 :math:`0` 开始递增的顺序依次在内存中 78 | (我们之前提到过页表的容量过大无法保存在 CPU 中)放置每个虚拟页号对应的页表项。由于每个页表项的大小是 :math:`8` 79 | 字节,我们只要知道第一个页表项(对应虚拟页号 :math:`0` )被放在的物理地址 :math:`\text{base_addr}` ,就能 80 | 直接计算出每个输入的虚拟页号对应的页表项所在的位置。如下图所示: 81 | 82 | .. image:: linear-table.png 83 | :height: 400 84 | :align: center 85 | 86 | 事实上,对于虚拟页号 :math:`i` ,如果页表(每个应用都有一个页表,这里指其中某一个)的起始地址为 87 | :math:`\text{base_addr}` ,则这个虚拟页号对应的页表项可以在物理地址 :math:`\text{base_addr}+8i` 处找到。 88 | 这使得 MMU 的实现和内核的软件控制都变得非常简单。然而遗憾的是,这远远超出了我们的物理内存限制。由于虚拟页号有 89 | :math:`2^{27}` 种,每个虚拟页号对应一个 :math:`8` 字节的页表项,则每个页表都需要消耗掉 :math:`1\text{GiB}` 90 | 内存!应用的数据还需要保存在内存的其他位置,这就使得每个应用要吃掉 :math:`1\text{GiB}` 以上的内存。作为对比, 91 | 我们的 K210 开发板目前只有 :math:`8\text{MiB}` 的内存,因此从空间占用角度来说,这种线性表实现是完全不可行的。 92 | 93 | 线性表的问题在于:它保存了所有虚拟页号对应的页表项,但是高达 :math:`512\text{GiB}` 的地址空间中真正会被应用 94 | 使用到的只是其中极小的一个子集(本教程中的应用内存使用量约在数十~数百 :math:`\text{KiB}` 量级),也就导致 95 | 有意义并能在页表中查到实际的物理页号的虚拟页号在 :math:`2^{27}` 中也只是很小的一部分。由此线性表的绝大部分空间 96 | 其实都是被浪费掉的。 97 | 98 | 那么如何进行优化呢?核心思想就在于 **按需分配** ,也就是说:有多少合法的虚拟页号,我们就维护一个多大的映射,并为此使用 99 | 多大的内存用来保存映射。这是因为,每个应用的地址空间最开始都是空的,或者说所有的虚拟页号均不合法,那么这样的页表 100 | 自然不需要占用任何内存, MMU 在地址转换的时候无需关心页表的内容而是将所有的虚拟页号均判为不合法即可。而在后面, 101 | 内核已经决定好了一个应用的各逻辑段存放位置之后,它就需要负责从零开始以虚拟页面为单位来让该应用的地址空间的某些部分 102 | 变得合法,反映在该应用的页表上也就是一对对映射顺次被插入进来,自然页表所占据的内存大小也就逐渐增加。 103 | 104 | 这种思想在计算机科学中得到了广泛应用:为了方便接下来的说明,我们可以举一道数据结构的题目作为例子。设想我们要维护 105 | 一个字符串的多重集,集合中所有的字符串的字符集均为 :math:`\alpha=\{a,b,c\}` ,长度均为一个给定的常数 106 | :math:`n` 。该字符串集合一开始为空集。我们要支持两种操作,第一种是将一个字符串插入集合,第二种是查询一个字符串在当前 107 | 的集合中出现了多少次。 108 | 109 | .. _term-trie: 110 | 111 | 简单起见,假设 :math:`n=3` 。那么我们可能会建立这样一颗 **字典树** (Trie) : 112 | 113 | .. image:: trie.png 114 | 115 | 字典树由若干个节点(图中用椭圆形来表示)组成,从逻辑上而言每个节点代表一个可能的字符串前缀。每个节点的存储内容 116 | 都只有三个指针,对于蓝色的非叶节点来说,它的三个指针各自指向一个子节点;而对于绿色的叶子节点来说,它的三个指针不再指向 117 | 任何节点,而是具体保存一种可能的长度为 :math:`n` 的字符串的计数。这样,对于题目要求的两种操作,我们只需根据输入的 118 | 字符串中的每个字符在字典树上自上而下对应走出一步,最终就能够找到字典树中维护的它的计数。之后我们可以将其直接返回或者 119 | 加一。 120 | 121 | 注意到如果某些字符串自始至终没有被插入,那么一些节点没有存在的必要。反过来说一些节点是由于我们插入了一个以它对应的字符串 122 | 为前缀的字符串才被分配出来的。如下图所示: 123 | 124 | .. image:: trie-1.png 125 | 126 | 一开始仅存在一个根节点。在我们插入字符串 ``acb`` 的过程中,我们只需要分配 ``a`` 和 ``ac`` 两个节点。 127 | 注意 ``ac`` 是一个叶节点,它的 ``b`` 指针不再指向另外一个节点而是保存字符串 ``acb`` 的计数。 128 | 此时我们无法访问到其他未分配的节点,如根节点的 ``b/c`` 或是 ``a`` 节点的 ``a/b`` 均为空指针。 129 | 如果后续再插入一个字符串,那么 **至多分配两个新节点** ,因为如果走的路径上有节点已经存在,就无需重复分配了。 130 | 这可以说明,字典树中节点的数目(或者说字典树消耗的内存)是随着插入字符串的数目逐渐线性增加的。 131 | 132 | 读者可能很好奇,为何在这里要用相当一部分篇幅来介绍字典树呢?事实上 SV39 分页机制等价于一颗字典树。 :math:`27` 位的 133 | 虚拟页号可以看成一个长度 :math:`n=3` 的字符串,字符集为 :math:`\alpha=\{0,1,2,...,511\}` ,因为每一位字符都 134 | 由 :math:`9` 个比特组成。而我们也不再维护所谓字符串的计数,而是要找到字符串(虚拟页号)对应的页表项。 135 | 因此,每个叶节点都需要保存 :math:`512` 个 :math:`8` 字节的页表项,一共正好 :math:`4\text{KiB}` , 136 | 可以直接放在一个物理页帧内。而对于非叶节点来说,从功能上它只需要保存 :math:`512` 个指向下级节点的指针即可, 137 | 不过我们就像叶节点那样也保存 :math:`512` 个页表项,这样所有的节点都可以被放在一个物理页帧内,它们的位置可以用一个 138 | 物理页号来代替。当想从一个非叶节点向下走时,只需找到当前字符对应的页表项的物理页号字段,它就指向了下一级节点的位置, 139 | 这样非叶节点中转的功能也就实现了。每个节点的内部是一个线性表,也就是将这个节点起始物理地址加上字符对应的偏移量就找到了 140 | 指向下一级节点的页表项(对于非叶节点)或是能够直接用来地址转换的页表项(对于叶节点)。 141 | 142 | .. _term-multi-level-page-table: 143 | .. _term-page-index: 144 | 145 | 这种页表实现被称为 **多级页表** (Multi-Level Page-Table) 。由于 SV39 中虚拟页号被分为三级 **页索引** 146 | (Page Index) ,因此这是一种三级页表。 147 | 148 | 非叶节点的页表项标志位含义和叶节点相比有一些不同: 149 | 150 | - 当 V 为 0 的时候,代表当前指针是一个空指针,无法走向下一级节点,即该页表项对应的虚拟地址范围是无效的; 151 | - 只有当V 为1 且 R/W/X 均为 0 时,表示是一个合法的页目录表项,其包含的指针会指向下一级的页表。 152 | - 注意: 当V 为1 且 R/W/X 不全为 0 时,表示是一个合法的页表项,其包含了虚地址对应的物理页号。 153 | 154 | 在这里我们给出 SV39 中的 R/W/X 组合的含义: 155 | 156 | .. image:: pte-rwx.png 157 | :align: center 158 | :height: 250 159 | 160 | .. _term-huge-page: 161 | 162 | .. note:: 163 | 164 | **大页** (Huge Page) 165 | 166 | 本教程中并没有用到大页的知识,这里只是作为拓展,不感兴趣的读者可以跳过。 167 | 168 | 事实上正确的说法应该是:只要 R/W/X 不全为 0 就会停下来,直接从当前的页表项中取出物理页号进行最终的地址转换。 169 | 如果这一过程并没有发生在多级页表的最深层,那么在地址转换的时候并不是直接将物理页号和虚拟地址中的页内偏移接 170 | 在一起得到物理地址,这样做会有问题:由于有若干级页索引并没有被使用到,即使两个虚拟地址的这些级页索引不同, 171 | 还是会最终得到一个相同的物理地址,导致冲突。 172 | 173 | 我们需要重新理解将物理页号和页内偏移“接起来”这一行为,它的本质是将物理页号对应的物理页帧的起始物理地址和 174 | 页内偏移进行求和,前者是将物理页号左移上页内偏移的位数得到,因此看上去恰好就是将物理页号和页内偏移接在一起。 175 | 但是如果在从多级页表往下走的中途停止,未用到的页索引会和虚拟地址的 :math:`12` 位页内偏移一起形成一个 176 | 位数更多的页内偏移,也就对应于一个大页,在转换物理地址的时候,其算法仍是上述二者求和,但那时便不再是简单的 177 | 拼接操作。 178 | 179 | 在 SV39 中,如果使用了一级页索引就停下来,则它可以涵盖虚拟页号的前 :math:`9` 位为某一固定值的所有虚拟地址, 180 | 对应于一个 :math:`1\text{GiB}` 的大页;如果使用了二级页索引就停下来,则它可以涵盖虚拟页号的前 181 | :math:`18` 位为某一固定值的所有虚拟地址,对应于一个 :math:`2\text{MiB}` 的大页。以同样的视角,如果使用了 182 | 所有三级页索引才停下来,它可以涵盖虚拟页号为某一个固定值的所有虚拟地址,自然也就对应于一个大小为 183 | :math:`4\text{KiB}` 的虚拟页面。 184 | 185 | 使用大页的优点在于,当地址空间的大块连续区域的访问权限均相同的时候,可以直接映射一个大页,从时间上避免了大量 186 | 页表项的索引和修改,从空间上降低了所需节点的数目。但是,从内存分配算法的角度,这需要内核支持从物理内存上分配 187 | 三种不同大小的连续区域( :math:`4\text{KiB}` 或是另外两种大页),便不能使用更为简单的插槽式管理。权衡利弊 188 | 之后,本书全程只会以 :math:`4\text{KiB}` 为单位进行页表映射而不会使用大页特性。 189 | 190 | 那么 SV39 多级页表相比线性表到底能节省多少内存呢?这里直接给出结论:设某个应用地址空间实际用到的区域总大小为 191 | :math:`S` 字节,则地址空间对应的多级页表消耗内存为 :math:`\frac{S}{512}` 左右。下面给出了详细分析,对此 192 | 不感兴趣的读者可以直接跳过。 193 | 194 | .. note:: 195 | 196 | **分析 SV39 多级页表的内存占用** 197 | 198 | 我们知道,多级页表的总内存消耗取决于节点的数目,每个节点 199 | 则需要一个大小为 :math:`4\text{KiB}` 物理页帧存放。不妨设某个应用地址空间中的实际用到的总空间大小为 :math:`S` 200 | 字节,则多级页表所需的内存至少有这样两个上界: 201 | 202 | - 每映射一个 :math:`4\text{KiB}` 的虚拟页面,最多需要新分配两个物理页帧来保存新的节点,加上初始就有一个根节点, 203 | 因此消耗内存不超过 204 | :math:`4\text{KiB}\times(1+2\frac{S}{4\text{KiB}})=4\text{KiB}+2S` ; 205 | - 考虑已经映射了很多虚拟页面,使得根节点的 :math:`512` 个孩子节点都已经被分配的情况,此时最坏的情况是每次映射 206 | 都需要分配一个不同的最深层节点,加上根节点的所有孩子节点并不一定都被分配,从这个角度来讲消耗内存不超过 207 | :math:`4\text{KiB}\times(1+512+\frac{S}{4\text{KiB}})=4\text{KiB}+2\text{MiB}+S` 。 208 | 209 | 虽然这两个上限都可以通过刻意构造一种地址空间的使用来达到,但是它们看起来很不合理,因为它们均大于 :math:`S` ,也就是 210 | 元数据比数据还大。其实,真实环境中一般不会有如此极端的使用方式,更加贴近 211 | 实际的是下面一种上限:即除了根节点的一个物理页帧之外,地址空间中的每个实际用到的大小为 :math:`T` 字节的 *连续* 区间 212 | 会让多级页表额外消耗不超过 :math:`4\text{KiB}\times(\lceil\frac{T}{2\text{MiB}}\rceil+\lceil\frac{T}{1\text{GiB}}\rceil)` 213 | 的内存。这是因为,括号中的两项分别对应为了映射这段连续区间所需要新分配的最深层和次深层节点的数目,前者每连续映射 214 | :math:`2\text{MiB}` 才会新分配一个,而后者每连续映射 :math:`1\text{GiB}` 才会新分配一个。由于后者远小于前者, 215 | 可以将后者忽略,最后得到的结果近似于 :math:`\frac{T}{512}` 。而一般情况下我们对于地址空间的使用方法都是在其中 216 | 放置少数几个连续的逻辑段,因此当一个地址空间实际使用的区域大小总和为 :math:`S` 字节的时候,我们可以认为为此多级页表 217 | 消耗的内存在 :math:`\frac{S}{512}` 左右。相比线性表固定消耗 :math:`1\text{GiB}` 的内存,这已经相当可以 218 | 接受了。 219 | 220 | 上面主要是对一个固定应用的多级页表进行了介绍。在一个多任务系统中,可能同时存在多个任务处于运行/就绪状态,它们的多级页表 221 | 在内存中共存,那么 MMU 应该如何知道当前做地址转换的时候要查哪一个页表呢?回到 :ref:`satp CSR 的布局 ` , 222 | 其中的 PPN 字段指的就是多级页表根节点所在的物理页号。因此,每个应用的地址空间就可以用包含了它多级页表根节点所在物理页号 223 | 的 ``satp`` CSR 代表。在我们切换任务的时候, ``satp`` 也必须被同时切换。 224 | 225 | 最后的最后,我们给出 SV39 地址转换的全过程图示来结束多级页表原理的介绍: 226 | 227 | .. image:: sv39-full.png 228 | :height: 600 229 | :align: center 230 | -------------------------------------------------------------------------------- /source/chapter4/7exercise.rst: -------------------------------------------------------------------------------- 1 | chapter4练习 2 | ============================================ 3 | 4 | - 本节难度: **有一定困难,尽早开始** 5 | 6 | 7 | 本章任务 8 | ------------------------------------------- 9 | 10 | - ``make test BASE=1`` 11 | - 理解 vm.c 中的几个函数的大致功能,通过 bin_loader 理解当前用户程序的虚存布局。 12 | - 结合课堂内容,完成本章问答作业。 13 | - 完成本章编程作业。 14 | - 最终,完成实验报告并 push 你的 ch4 分支到远程仓库。 15 | 16 | 编程作业 17 | --------------------------------------------- 18 | 19 | 重新实现 sys_gettimeofday以及 taskinfo 20 | ++++++++++++++++++++++++++++++++++++++++++++ 21 | 22 | 引入虚存机制后,原来内核的 sys_gettimeofday 以及对应的获取 taskinfo 信息的函数实现就无效了。请你重写这个函数,恢复其正常功能。 23 | 24 | 完成后你应该能够正确执行 ch3b_sleep* 以及 ch3_taskinfo 对应的测例。通过 ``make test CHAPTER=4_3 BASE=1`` 来测试你的实现。 25 | 26 | tips: 27 | 28 | - 抄框架其他传指针的 syscall 实现。 29 | 30 | mmap 匿名映射 31 | ++++++++++++++++++++++++++++++++++++++++++++ 32 | 33 | 你有没有想过,当你在 C++ 语言中写下的 ``new int[100];`` 执行时可能会发生哪些事情?你可能已经发现,目前我们给用户程序的内存都是固定的并没有增长的能力,这些程序是不能执行 ``new`` 这类导致内存使用增加的操作。libc 中通过 `sbrk `_ 系统调用增加进程可使用的堆空间,这也是本来的题目设计,但是一位热心的往年助教J学长表示:这一点也不酷!他推荐了另一个申请内存的系统调用。 34 | 35 | `mmap `_ 本身主要使用来在内存中映射文件的,这里我们简化它的功能,仅仅使用匿名映射。 36 | 37 | mmap 系统调用新定义: 38 | 39 | - syscall ID:222 40 | - 接口: ``int mmap(void* start, unsigned long long len, int port, int flag, int fd)`` 41 | - 功能:申请长度为 len 字节的匿名物理内存(不要求实际物理内存位置,可以随便找一块),并映射到 addr 开始的虚存,内存页属性为 port。 42 | - 参数: 43 | - start:需要映射的虚存起始地址。 44 | - len:映射字节长度,可以为 0 (如果是则直接返回),不可过大(上限 1GiB )。 45 | - port:第 0 位表示是否可读,第 1 位表示是否可写,第 2 位表示是否可执行。其他位无效(必须为 0 )。 46 | - flag:目前始终为 0,忽略该参数。 47 | - fd:目前始终为 0, 忽略该参数。 48 | - 返回值: 49 | - 成功返回 0,错误返回 -1。 50 | - 说明: 51 | - 为了简单,addr 要求按页对齐(否则报错),len 可直接按页上取整。 52 | - 为了简单,不考虑分配失败时的页回收。 53 | - flag, fd 参数留待后续实验拓展。 54 | - 错误: 55 | - [addr, addr + len) 存在已经被映射的页。 56 | - 物理内存不足。 57 | - port & ~0x7 == 0,port 其他位必须为 0 58 | - port & 0x7 != 0,不可读不可写不可执行的内存无意义 59 | 60 | munmap 系统调用新定义: 61 | 62 | - syscall ID:215 63 | - 接口: ``int munmap(void* start, unsigned long long len)`` 64 | - 功能:取消一块虚存的映射。 65 | - 参数:同 mmap 66 | - 说明: 67 | - 为了简单,参数错误时不考虑内存的恢复和回收。 68 | - 错误: 69 | - [start, start + len) 中存在未被映射的虚存。 70 | 71 | 72 | 正确实现后,你的 os 应该能够正确运行 ch4_* 对应的一些测试用例,``make test BASE=0`` 来执行测试。 73 | 74 | tips: 75 | 76 | - 匿名映射的页可以使用 kalloc() 得到。 77 | - 注意 kalloc 不支持连续物理内存分配,所以你必须把多个页的 mmap 逐页进行映射。 78 | - 一定要注意 mmap 是的页表项,注意 riscv 页表项的格式与 port 的区别。 79 | - 你增加 PTE_U 了吗? 80 | 81 | 82 | 问答作业 83 | ------------------------------------------------- 84 | 85 | 1. 请列举 SV39 页表页表项的组成,结合课堂内容,描述其中的标志位有何作用/潜在作用? 86 | 87 | 2. 缺页 88 | 89 | 这次的实验没有涉及到缺页有点遗憾,主要是缺页难以测试,而且更多的是一种优化,不符合这次实验的核心理念,所以这里补两道小题。 90 | 91 | 缺页指的是进程访问页面时页面不在页表中或在页表中无效的现象,此时 MMU 将会返回一个中断,告知 os 进程内存访问出了问题。os 选择填补页表并重新执行异常指令或者杀死进程。 92 | 93 | - 请问哪些异常可能是缺页导致的? 94 | - 发生缺页时,描述相关的重要寄存器的值(lab2中描述过的可以简单点)。 95 | 96 | 缺页有两个常见的原因,其一是 Lazy 策略,也就是直到内存页面被访问才实际进行页表操作。比如,一个程序被执行时,进程的代码段理论上需要从磁盘加载到内存。但是 os 并不会马上这样做,而是会保存 .text 段在磁盘的位置信息,在这些代码第一次被执行时才完成从磁盘的加载操作。 97 | 98 | - 这样做有哪些好处? 99 | 100 | 此外 COW(Copy On Write) 也是常见的容易导致缺页的 Lazy 策略,这个之后再说。其实,我们的 mmap 也可以采取 Lazy 策略,比如:一个用户进程先后申请了 10G 的内存空间,然后用了其中 1M 就直接退出了。按照现在的做法,我们显然亏大了,进行了很多没有意义的页表操作。 101 | 102 | - 请问处理 10G 连续的内存页面,需要操作的页表实际大致占用多少内存(给出数量级即可)? 103 | - 请简单思考如何才能在现有框架基础上实现 Lazy 策略,缺页时又如何处理?描述合理即可,不需要考虑实现。 104 | 105 | 缺页的另一个常见原因是 swap 策略,也就是内存页面可能被换到磁盘上了,导致对应页面失效。 106 | 107 | - 此时页面失效如何表现在页表项(PTE)上? 108 | 109 | 3. 双页表与单页表 110 | 111 | 为了防范侧信道攻击,我们的 os 使用了双页表。但是传统的设计一直是单页表的,也就是说,用户线程和对应的内核线程共用同一张页表,只不过内核对应的地址只允许在内核态访问。请结合课堂知识回答如下问题:(备注:这里的单/双的说法仅为自创的通俗说法,并无这个名词概念,详情见 `KPTI `_ ) 112 | 113 | - 单页表情况下,如何更换页表? 114 | - 单页表情况下,如何控制用户态无法访问内核页面?(tips:看看第一题最后一问) 115 | - 单页表有何优势?(回答合理即可) 116 | - 双页表实现下,何时需要更换页表?假设你写一个单页表操作系统,你会选择何时更换页表(回答合理即可)? 117 | 118 | 报告要求 119 | -------------------------------------------------------- 120 | - 注意目录要求,报告命名 ``lab2.md``(或 pdf),位于 ``reports`` 目录下。命名错误视作没有提交。不需要删除 ``lab1.md``。后续实验同理。 121 | - 简单总结本次实验你新添加的代码。 122 | - 完成 ch4 问答作业。 123 | - [可选,不占分]你对本次实验设计及难度的看法。 124 | -------------------------------------------------------------------------------- /source/chapter4/address-translation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/address-translation.png -------------------------------------------------------------------------------- /source/chapter4/app-as-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/app-as-full.png -------------------------------------------------------------------------------- /source/chapter4/index.rst: -------------------------------------------------------------------------------- 1 | 第四章:地址空间 2 | ============================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | 0intro 8 | 1rust-dynamic-allocation 9 | 2address-space 10 | 3sv39-implementation-1 11 | 4sv39-implementation-2 12 | 7exercise 13 | -------------------------------------------------------------------------------- /source/chapter4/kernel-as-high.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/kernel-as-high.png -------------------------------------------------------------------------------- /source/chapter4/kernel-as-low.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/kernel-as-low.png -------------------------------------------------------------------------------- /source/chapter4/linear-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/linear-table.png -------------------------------------------------------------------------------- /source/chapter4/page-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/page-table.png -------------------------------------------------------------------------------- /source/chapter4/pte-rwx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/pte-rwx.png -------------------------------------------------------------------------------- /source/chapter4/rust-containers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/rust-containers.png -------------------------------------------------------------------------------- /source/chapter4/satp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/satp.png -------------------------------------------------------------------------------- /source/chapter4/segmentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/segmentation.png -------------------------------------------------------------------------------- /source/chapter4/simple-base-bound.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/simple-base-bound.png -------------------------------------------------------------------------------- /source/chapter4/sv39-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/sv39-full.png -------------------------------------------------------------------------------- /source/chapter4/sv39-pte.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/sv39-pte.png -------------------------------------------------------------------------------- /source/chapter4/sv39-va-pa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/sv39-va-pa.png -------------------------------------------------------------------------------- /source/chapter4/trie-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/trie-1.png -------------------------------------------------------------------------------- /source/chapter4/trie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter4/trie.png -------------------------------------------------------------------------------- /source/chapter5/0intro.rst: -------------------------------------------------------------------------------- 1 | 引言 2 | =========================================== 3 | 4 | 本章导读 5 | ------------------------------------------- 6 | 7 | 本章不同于前面几章对OS框架的大量修改,主要着力于增加OS对于进程管理的支持。因此内容和难度相比前面都会轻松很多~~. 8 | 9 | 支持了页表之后,我们的操作系统在硬件上的支持就告一段落了。但是目前我们应用测例的执行方式还是十分机械化的,并且无法和用户交互。目前为止,所有的应用都是在内核初始化阶段被一并加载到内存中的,之后也无法对应用进行动态增删,从用户的角度来看这和第二章的批处理系统似乎并没有什么不同。 10 | 11 | 于是,本章我们会开发一个用户 **终端** (Terminal) 或称 **命令行** 应用(Command Line Application, 俗称 **Shell** ) ,形成用户与操作系统进行交互的命令行界面(Command Line Interface),它就和我们今天常用的 OS 中的命令行应用(如 Linux中的bash,Windows中的CMD等)没有什么不同:只需在其中输入命令即可启动或杀死应用,或者监控系统的运行状况。这自然是现代 OS 中不可缺少的一部分,并大大增加了系统的 **可交互性** ,使得用户可以更加灵活地控制系统。 12 | 13 | 我们想一下shell执行命令的过程。首先,shell必须支持读入用户的输入,并且如果我们在shell之中运行一个测例程序,它需要创建一个新的进程来执行这个命令对应的执行流。这里shell本身对于OS来说,也是一个进程。这就意味着我们需要支持进程创建进程的系统调用。实际上,在第四章添加了页表支持之后,现在我们可以开始实现几个进程非常关键的系统调用了,它们都是大家在课堂上已经耳熟能详的函数:: 14 | 15 | sys_read(int fd, char* buf, int size): 从标准输入读取若干个字节。 16 | sys_fork(): 创建一个与当前进程几乎完全一致的进程。 17 | sys_exec(char* filename): 修改当前进程,使其从头开始执行指定程序。 18 | sys_wait(int pid, int* exit_code): 等待某一个或者任意一个子进程结束,获取其 exit_code。 19 | 20 | 21 | 实践体验 22 | ------------------------------------------- 23 | 24 | 获取本章代码: 25 | 26 | .. code-block:: console 27 | 28 | $ git checkout ch5 29 | 30 | 在 qemu 模拟器上运行本章代码: 31 | 32 | .. code-block:: console 33 | 34 | $ make test BASE=1 35 | 36 | # .... 37 | app list: 38 | ch2b_exit 39 | ch2b_hello_world 40 | ch2b_power 41 | ch2b_write1 42 | ch3b_sleep 43 | ch3b_sleep1 44 | ch3b_yield0 45 | ch3b_yield1 46 | ch3b_yield2 47 | ch5b_exec_simple 48 | ch5b_exit 49 | ch5b_forktest0 50 | ch5b_forktest1 51 | ch5b_forktest2 52 | ch5b_getpid 53 | ch5b_usertest 54 | usershell 55 | C user shell 56 | >> 57 | 58 | 不出意外,你将最终运行进入 C suer shell,这里,你可以输入 app list 中的一个应用,敲击回车之后就可以运行。其中 ``ch5b_usertest`` 打包了很多应用,只要执行它就能够自动执行所有基础测试: 59 | 60 | .. code-block:: bash 61 | 62 | >> ch2b_exit 63 | Shell: Process 2 exited with code 1234 64 | >> ch2b_hello_world 65 | Hello world from user mode program! 66 | Test hello_world OK! 67 | Shell: Process 3 exited with code 0 68 | 69 | 当应用执行完毕后,将继续回到shell程序的命令输入模式。另外,这个命令行支持退格键。 70 | 71 | 72 | 本章代码导读 73 | ----------------------------------------------------- 74 | 75 | 本章对于框架没有大量修改的代码。由于添加的系统调用是针对进程方面的,除了在syscall.c之中添加了相关接口的定义之外,主要函数的实现都在proc.c之中完成。此外,为了方便大家理解本章的进程调度部分内容,加入了queue.c文件,定义了一个就绪进程的一个队列。 76 | 77 | 我们已经完成了对上述几系统调用的支持,在开始本章的练习之前,大家需要仔细研究它们的实现细节,可以复习课堂上的知识,并且大大降低练习的难度。 -------------------------------------------------------------------------------- /source/chapter5/1process.rst: -------------------------------------------------------------------------------- 1 | 与进程有关的重要系统调用 2 | ================================================ 3 | 4 | 进程复习 5 | ------------------------- 6 | 7 | 本章添加了一系列的系统调用,主要修改的是进程的结构体以及针对系统调用的支持,以及部分关于进程调度相关的数据结构。 8 | 9 | 我们看一看我们进程支持的状态:: 10 | 11 | UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE 12 | 13 | 其中的ZOMBIE(僵尸)状态在本章开始我们就可能遇到了。ZOMBIE在我们的OS中可能会在如下情景出现:一个进程存在父进程且在父进程未结束时就结束,在等待父进程释放其资源时,我们设定其处于ZOMBIE态。 14 | 15 | 对其他部分有点忘的同学可以复习一下ch3的实验~。 16 | 17 | .. note:: 18 | 19 | **进程,线程和协程** 20 | 21 | 进程,线程和协程是操作系统中经常出现的名词,它们都是操作系统中的抽象概念,有联系和共同的地方,但也有区别。计算机的核心是CPU,它承担了基本上所有的计算任务;而操作系统是计算机的管理者,它可以以进程,线程和协程为基本的管理和调度单位来使用CPU执行具体的程序逻辑。 22 | 23 | 从历史角度上看,它们依次出现的顺序是进程、线程和协程。在还没有进程抽象的早期操作系统中,计算机科学家把程序在计算机上的一次执行过程称为一个任务(task)或一个工作(job),其特点是任务和工作在其整个的执行过程中,不会被切换。这样其他任务必须等待一个任务结束后,才能执行,这样系统的效率会比较低。 24 | 25 | 在引入面向CPU的分时切换机制和面向内存的虚拟内存机制后,进程的概念就被提出了,进程成为CPU(也称处理器)调度(scheduling)和分派(switch)的对象,各个进程间以时间片为单位轮流使用CPU,且每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。这时,操作系统通过进程这个抽象来完成对应用程序在CPU和内存使用上的管理。 26 | 27 | 随着计算机的发展,对计算机系统性能的要求越来越高,而进程之间的切换开销相对较大,于是计算机科学家就提出了线程。线程是程序执行中一个单一的顺序控制流程,线程是进程的一部分,一个进程可以包含一个或多个线程。各个线程之间共享进程的地址空间,但线程要有自己独立的栈(用于函数访问,局部变量等)和独立的控制流。且线程是处理器调度和分派的基本单位。对于线程的调度和管理,可以在操作系统层面完成,也可以在用户态的线程库中完成。用户态线程也称为绿色线程(GreenThread)。如果是在用户态的线程库中完成,操作系统是“看不到”这样的线程的,也就谈不上对这样线程的管理了。 28 | 29 | 协程(coroutines,也称纤程(Fiber)),也是程序执行中一个单一的顺序控制流程,建立在线程之上(即一个线程上可以有多个协程),但又比线程更加轻量级的处理器调度对象。协程一般是由用户态的协程管理库来进行管理和调度,这样操作系统是看不到协程的。而且多个协程共享同一线程的栈,这样协程在时间和空间的管理开销上,相对于线程又有很大的改善。在具体实现上,协程可以在用户态运行时库这一层面通过函数调用来实现;也可在语言级支持协程,比如Rust语言引入的 ``async`` 、 ``wait`` 关键字等,通过编译器和运行时库二者配合来简化程序员编程的负担并提高整体的性能。 30 | 31 | 重要系统调用 32 | ------------------------------------------------------------ 33 | 34 | fork 系统调用 35 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 36 | 37 | .. _term-pid: 38 | .. _term-initial-process: 39 | 40 | 系统中同一时间存在的每个进程都被一个不同的 **进程标识符** (PID, Process Identifier) 所标识。在内核初始化完毕之后会创建一个进程——即 **用户初始进程** (Initial Process) ,它是目前在内核中以硬编码方式创建的唯一一个进程。其他所有的进程都是通过一个名为 ``fork`` 的系统调用来创建的。 41 | 42 | 首先,创建一个进程,就意味着我们要完成对应进程的PCB结构体以及其页表和栈的初始化等等。 43 | 44 | .. code-block:: c 45 | 46 | // os/proc.c 47 | struct proc { 48 | enum procstate state; 49 | int pid; 50 | pagetable_t pagetable; 51 | uint64 ustack; 52 | uint64 kstack; 53 | struct trapframe *trapframe; 54 | struct context context; 55 | uint64 sz; // Memory size 56 | struct proc *parent; // Parent process 57 | uint64 exit_code; 58 | }; 59 | 60 | 61 | 进程A调用 ``fork`` 系统调用之后,内核会创建一个新进程B,我们设定B是成为A的子进程。也就会设定其parent指向A的地址。我们再来看一下fork是如何进行新进程的初始化的: 62 | 63 | .. code-block:: c 64 | 65 | int fork() 66 | { 67 | struct proc *p = curr_proc(); 68 | struct proc *np = allocproc(); 69 | // Copy user memory from parent to child. 70 | uvmcopy(p->pagetable, np->pagetable, p->max_page); 71 | np->max_page = p->max_page; 72 | // copy saved user registers. 73 | *(np->trapframe) = *(p->trapframe); 74 | // Cause fork to return 0 in the child. 75 | np->trapframe->a0 = 0; 76 | np->parent = p; 77 | np->state = RUNNABLE; 78 | return np->pid; 79 | } 80 | 81 | 首先,fork调用allocproc分配一个新的进程PCB(具体内容请见前几个lab,注意页表的初始化也在alloc时完成了)。之后,根据fork的规定,我们需要把进程A的内存拷贝至B的进程使得二者一样。我们不能仅仅拷贝一份一模一样的页表,那么父子进程就会修改同样的物理内存,发生数据冲突,不符合进程隔离的要求。需要把页表对应的页先拷贝一份,然后建立一个对这些新页有同样映射的页表。这一工作由一个 uvmcopy 的函数去做。uvmcopy函数会遍历A进程的页表,以页为单位将对应的内存复制到B进程页表中新kalloc的空闲地址之中。 82 | 83 | .. warning:: 84 | 85 | 注意 mmap 对于进程 max_page 的影响。在 ch4 中,即便实现错误导致了内存泄漏也不会有直接致命的影响,但在 lab5 就不是这样了!修复你的 mmap 实现! 86 | 87 | 之后,我们把A的trapframe也复制给B,确保了B能继续A的执行流。但是我们设定a0寄存器的值为a,这是因为fork要求子进程的fork返回值是0。之后就是对于PCB的状态设定。 88 | 89 | 全部处理完之后,我们就得到了fork的新进程,并且父进程此时的返回值就是子进程的pid。 90 | 91 | 这里大家要仔细思考一下,当调度的我们新生成的子进程B的时候,它的执行流具体是什么样子的?这个问题对于理解OS框架十分重要。提示:新进程的 context 是怎样的?allocproc 会在进程池中新增一个进程,那么调度到的这个进程会从哪里开始执行? 92 | 93 | wait 系统调用 94 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 95 | 96 | 在 fork 设 定好父子关系之后,wait 的实现就很简单了。我们通过直接遍历进程池数组来获得当前进程的所有子进程。我们来看一下具体系统调用的要求. 97 | 98 | .. code-block:: c 99 | 100 | /// pid 表示要等待结束的子进程的进程 ID,如果为 0或者-1 的话表示等待任意一个子进程结束; 101 | /// status 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。 102 | /// 返回值:如果出现了错误则返回 -1;否则返回结束的子进程的进程 ID。 103 | /// 如果子进程存在且尚未完成,该系统调用阻塞等待。 104 | /// pid 非法或者指定的不是该进程的子进程或传入的地址 status 不为 0 但是不合法均会导致错误。 105 | int waitpid(int pid, int *status); 106 | 107 | 来看一下具体waitpid的实现. 108 | 109 | .. code-block:: c 110 | 111 | int 112 | wait(int pid, int* code) 113 | { 114 | struct proc *np; 115 | int havekids; 116 | struct proc *p = curr_proc(); 117 | 118 | for(;;){ 119 | // Scan through table looking for exited children. 120 | havekids = 0; 121 | for(np = pool; np < &pool[NPROC]; np++){ 122 | if(np->state != UNUSED && np->parent == p && (pid <= 0 || np->pid == pid)){ 123 | havekids = 1; 124 | if(np->state == ZOMBIE){ 125 | // Found one. 126 | np->state = UNUSED; 127 | pid = np->pid; 128 | *code = np->exit_code; 129 | return pid; 130 | } 131 | } 132 | } 133 | if(!havekids){ 134 | return -1; 135 | } 136 | p->state = RUNNABLE; 137 | sched(); 138 | } 139 | } 140 | 141 | wait 的思路就是遍历进程数组,看有没有和 pid 匹配的进程。如果有且已经结束(ZOMBIE态),按要求返回。如果指定进程不存在或者不是当前进程子进程,返回错误。如果子进程存在但未结束,调用 sched 切换到其他进程来等待子进程结束。 142 | 143 | exec 系统调用 144 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 145 | 146 | 如果仅有 ``fork`` 的话,那么所有的进程都只能和用户初始进程一样执行同样的代码段,这显然是远远不够的。于是我们还需要引入 ``exec`` 系统调用来执行不同的可执行文件。exec要干的事情和 bin_loader 是很相似的。事实上,不同点在于,exec 需要先清理并回收掉当前进程占用的资源,目前只有内存。 147 | 148 | .. code-block:: c 149 | 150 | int exec(char *name) 151 | { 152 | int id = get_id_by_name(name); 153 | if (id < 0) 154 | return -1; 155 | struct proc *p = curr_proc(); 156 | uvmunmap(p->pagetable, 0, p->max_page, 1); 157 | p->max_page = 0; 158 | loader(id, p); 159 | return 0; 160 | } 161 | 162 | 我们exec的设计是传入待执行测例的文件名。之后会找到文件名对应的id。如果存在对应文件,就会执行内存的释放。 163 | 164 | 由于 trapframe 和 trampoline 是可以复用的(每个进程都一样),所以我们并不会把他们 unmap。而对于用户真正的数据,就会删掉映射的同时把物理页面也 free 掉。 165 | 166 | 之后就是执行 loader 函数,这个loader函数相较前面的章节有比较大的修改,我们会在下一节说明。 167 | 168 | 支持了fork和exec之后,我们就用拥有了支持shell的基本能力。 169 | 170 | .. _term-redirection: 171 | 172 | .. note:: 173 | 174 | **为何创建进程要通过两个系统调用而不是一个?** 175 | 176 | 读者可能会有疑问,对于要达成执行不同应用的目标,我们为什么不设计一个系统调用接口同时实现创建一个新进程并加载给定的可执行文件两种功能? 177 | 因为如果使用 ``fork`` 和 ``exec`` 的组合,那么 ``fork`` 出来的进程仅仅是为了 ``exec`` 一个新应用提供空间。而执行 ``fork`` 中对父进程的地址空间拷贝没有用处,还浪费了时间,且在后续清空地址空间的时候还会产生一些资源回收的额外开销。 178 | 然而这样做是经过实践考验的——事实上 ``fork`` 和 ``exec`` 是一种灵活的系统调用组合。上述的这些开销能够通过一些技术方法(如 ``copy on write`` 等)大幅降低,且拆分为两个系统调用后,可以灵活地支持 **重定向** (Redirection) 等功能。 179 | 上述方法是UNIX类操作系统的典型做法,这一点与Windows操作系统不一样。在Windows中, ``CreateProcess`` 函数用来创建一个新的进程和它的主线程,通过这个新进程运行指定的可执行文件。虽然是一个函数,但这个函数的参数十个之多,使得这个函数很复杂,且没有 ``fork`` 和 ``exec`` 的组合的灵活性。 180 | -------------------------------------------------------------------------------- /source/chapter5/2core-data-structures.rst: -------------------------------------------------------------------------------- 1 | 进程管理的核心数据结构 2 | =================================== 3 | 4 | 本节导读 5 | ----------------------------------- 6 | 7 | 本节将会展示在本章节的实验中,我们管理进程、调度进程所用到的数据结构。 8 | 9 | 进程队列 10 | ------------------------------------------------------------------------ 11 | 12 | 不同于此前遍历进程池的调度方式,在本章节中,我们实现了一个简单的队列,用于存储和调度所有的就绪进程: 13 | 14 | .. code-block:: c 15 | :linenos: 16 | 17 | // os/queue.h 18 | 19 | struct queue { 20 | int data[QUEUE_SIZE]; 21 | int front; 22 | int tail; 23 | int empty; 24 | }; 25 | 26 | void init_queue(struct queue *); 27 | void push_queue(struct queue *, int); 28 | int pop_queue(struct queue *); 29 | 30 | 队列的实现非常简单,大小为1024,具体的实现大家可以查看queue.c进行查看。我们将在后面的部分展示我们要如何使用这一数据结构 31 | 32 | 进程的调度 33 | ------------------------------------------------------------------------ 34 | 35 | 进程的调度主要体现在proc.c的scheduler函数中: 36 | 37 | .. code-block:: c 38 | :linenos: 39 | 40 | // os/proc.c 41 | 42 | void scheduler() 43 | { 44 | struct proc *p; 45 | for (;;) { 46 | /*int has_proc = 0; 47 | for (p = pool; p < &pool[NPROC]; p++) { 48 | if (p->state == RUNNABLE) { 49 | has_proc = 1; 50 | tracef("swtich to proc %d", p - pool); 51 | p->state = RUNNING; 52 | current_proc = p; 53 | swtch(&idle.context, &p->context); 54 | } 55 | } 56 | if(has_proc == 0) { 57 | panic("all app are over!\n"); 58 | }*/ 59 | p = fetch_task(); 60 | if (p == NULL) { 61 | panic("all app are over!\n"); 62 | } 63 | tracef("swtich to proc %d", p - pool); 64 | p->state = RUNNING; 65 | current_proc = p; 66 | swtch(&idle.context, &p->context); 67 | } 68 | } 69 | 70 | 71 | 72 | 可以看到,我们移除了原来遍历进程池,选出其中就绪状态的进程来运行的这种朴素调度方式,而是直接使用了fetch_task函数从队列中获取应当调度的进程,再进行相应的切换;而对于已经运行结束或时间片耗尽的进程,则将其push进入队列之中。这种调度方式,相比之前提高了调度的效率,可以在常数时间复杂度下完成一次调度。由于使用的是队列,因此大家也会发现,我们的框架代码所使用的FIFO的调度算法。 73 | -------------------------------------------------------------------------------- /source/chapter5/3shell-and-binloader.rst: -------------------------------------------------------------------------------- 1 | shell与测例的加载 2 | =================================== 3 | 4 | 本节导读 5 | ----------------------------------- 6 | 7 | 本节将会展示新的bin_loader加载测例到进程的方式,并且展示我们的shell测例是如何运行的。 8 | 9 | 新的bin_loader 10 | ------------------------------------------------------------------------ 11 | 12 | exec会调用bin_loader,将对应文件名的测例加载到指定的进程p之中。请结合注释理解 bin_loader 的变化: 13 | 14 | .. code-block:: c 15 | :linenos: 16 | 17 | int bin_loader(uint64 start, uint64 end, struct proc *p) 18 | { 19 | void *page; 20 | // 注意现在我们不要求对其了,代码的核心逻辑还是把 [start, end) 21 | // 映射到虚拟内存的 [BASE_ADDRESS, BASE_ADDRESS + length) 22 | uint64 pa_start = PGROUNDDOWN(start); 23 | uint64 pa_end = PGROUNDUP(end); 24 | uint64 length = pa_end - pa_start; 25 | uint64 va_start = BASE_ADDRESS; 26 | uint64 va_end = BASE_ADDRESS + length; 27 | // 不再一次 map 很多页面,而是逐页 map,为什么? 28 | for (uint64 va = va_start, pa = pa_start; pa < pa_end; 29 | va += PGSIZE, pa += PGSIZE) { 30 | // 这里我们不会直接映射,而是新分配一个页面,然后使用 memmove 进行拷贝 31 | // 这样就不会有对其的问题了,但为何这么做其实有更深层的原因。 32 | page = kalloc(); 33 | memmove(page, (const void *)pa, PGSIZE); 34 | // 这个 if 就是为了防止 start end 不对其导致拷贝了多余的内核数据 35 | // 我们需要手动把它们清空 36 | if (pa < start) { 37 | memset(page, 0, start - va); 38 | } else if (pa + PAGE_SIZE > end) { 39 | memset(page + (end - pa), 0, PAGE_SIZE - (end - pa)); 40 | } 41 | mappages(p->pagetable, va, PGSIZE, (uint64)page, PTE_U | PTE_R | PTE_W | PTE_X); 42 | } 43 | // 同 lab4 map user stack 44 | p->ustack = va_end + PAGE_SIZE; 45 | for (uint64 va = p->ustack; va < p->ustack + USTACK_SIZE; 46 | va += PGSIZE) { 47 | page = kalloc(); 48 | memset(page, 0, PGSIZE); 49 | mappages(p->pagetable, va, PGSIZE, (uint64)page, PTE_U | PTE_R | PTE_W); 50 | } 51 | // 设置 trapframe 52 | p->trapframe->sp = p->ustack + USTACK_SIZE; 53 | p->trapframe->epc = va_start; 54 | p->max_page = PGROUNDUP(p->ustack + USTACK_SIZE - 1) / PAGE_SIZE; 55 | p->state = RUNNABLE; 56 | return 0; 57 | } 58 | 59 | 其中,对于用户栈、trapframe、trampoline 的映射没有变化,但是对 .bin 数据的映射似乎面目全非了,竟然由一个循环完成。其实,这个循环的逻辑十分简单,就是对于 .bin 的每一页,都申请一个新页并进行内容拷贝,最后建立这一页的映射。之所以这么麻烦完全是由于我们的物理内存管理过于简陋,一次只能分配一个页,如果能够分配连续的物理页,那么这个循环可以被一个 mappages 替代。 60 | 61 | 那么另一个更重要的问题是,为什么要拷贝呢?想想 lab4 我们是怎么干的,直接把虚存和物理内存映射就好了,根本没有拷贝。那么,拷贝是为了什么呢?其实,按照 lab4 的做法,程序运行之后就会修改仅有一份的程序"原像",你会发现,lab4 的程序都是一次性的,如果第二次执行,会发现 .data 和 .bss 段数据都被上一次执行改掉了,不是初始化的状态。但是 lab4 的时候,每个程序最多执行一次,所以这么做是可以的。但在 lab5 所有程序都可能被无数次的执行,我们就必须对“程序原像”做保护,在“原像”的拷贝上运行程序了。 62 | 63 | 测例的执行 64 | ------------------------------------------------------------------------ 65 | 66 | 从本章开始,大家可以发现我们的 run_all_app 函数被 load_init_app 取代了: 67 | 68 | .. code-block:: c 69 | :linenos: 70 | 71 | // os/loader.c 72 | 73 | // load all apps and init the corresponding `proc` structure. 74 | int load_init_app() 75 | { 76 | int id = get_id_by_name(INIT_PROC); 77 | if (id < 0) 78 | panic("Cannpt find INIT_PROC %s", INIT_PROC); 79 | struct proc *p = allocproc(); 80 | if (p == NULL) { 81 | panic("allocproc\n"); 82 | } 83 | debugf("load init proc %s", INIT_PROC); 84 | loader(id, p); 85 | return 0; 86 | } 87 | 88 | 这个 load_init_app load 的 INIT_PROC 一般来说就是我们在本章第一节展示的那个 usershell,不过可以通过在 Makefile 中传入 INIT_PROC 参数而改变,大部分情况下,不推荐修改,这是由于 usershell 具有不错的灵活性。 89 | 90 | 91 | usershell 92 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 93 | 94 | ``user/src/usershell.c`` 就是 usershell 的代码了,有兴趣的同学可以研究下这个 shell: 95 | 96 | .. code-block:: c 97 | 98 | const unsigned char LF = 0x0a; 99 | const unsigned char CR = 0x0d; 100 | const unsigned char DL = 0x7f; 101 | const unsigned char BS = 0x08; 102 | 103 | // 手搓了一个极简的 stack,用来维护用户输入,保存一行的输入 104 | char line[100] = {}; 105 | int top = 0; 106 | void push(char c){ line[top++] = c; } 107 | void pop() { --top; } 108 | int is_empty() { return top == 0;} 109 | void clear() { top = 0; } 110 | 111 | int main() 112 | { 113 | printf("C user shell\n"); 114 | printf(">> "); 115 | fflush(stdout); 116 | while (1) { 117 | char c = getchar(); 118 | switch (c) { 119 | // 回车,执行当前 stack 中字符串对应的程序 120 | case LF: 121 | case CR: 122 | printf("\n"); 123 | if (!is_empty()) { 124 | push('\0'); 125 | int pid = fork(); 126 | if (pid == 0) { 127 | // child process 128 | if (exec(line, NULL) < 0) { 129 | printf("no such program: %s\n", 130 | line); 131 | exit(0); 132 | } 133 | panic("unreachable!"); 134 | } else { 135 | int xstate = 0; 136 | int exit_pid = 0; 137 | exit_pid = waitpid(pid, &xstate); 138 | assert(pid == exit_pid); 139 | printf("Shell: Process %d exited with code %d\n", 140 | pid, xstate); 141 | } 142 | clear(); 143 | } 144 | printf(">> "); 145 | fflush(stdout); 146 | break; 147 | // 退格建,pop一个char 148 | case BS: 149 | case DL: 150 | if (!is_empty()) { 151 | putchar(BS); 152 | printf(" "); 153 | putchar(BS); 154 | fflush(stdout); 155 | pop(); 156 | } 157 | break; 158 | // 普通输入,回显并 push 一个 char 159 | default: 160 | putchar(c); 161 | fflush(stdout); 162 | push(c); 163 | break; 164 | } 165 | } 166 | return 0; 167 | } 168 | 169 | 170 | 可以看到这个测例实际上就是实现了一个简单的字符串处理的函数,并且针对解析得到的不同的指令调用不同的系统调用。要注意这需要shell支持read的系统调用。当读入用户的输入时,它会死循环的等待用户输入一个代表程序名称的字符串(通过sys_read),当用户按下空格之后,shell 会使用 fork 和 exec 创建并执行这个程序,然后通过 sys_wait 来等待程序执行结束,并输出 exit_code。有了 shell 之后,我们可以只执行自己希望的程序,也可以执行某一个程序很多次来观察输出,这对于使用体验是极大的提升!可以说,第五章的所有努力都是为了支持 shell。 171 | 172 | 我们简单看一下sys_read的实现,它与 sys_write 有点相似: 173 | 174 | .. code-block:: c 175 | 176 | uint64 sys_read(int fd, uint64 va, uint64 len) 177 | { 178 | if (fd != STDIN) 179 | return -1; 180 | struct proc *p = curr_proc(); 181 | char str[MAX_STR_LEN]; 182 | len = MIN(len, MAX_STR_LEN); 183 | for (int i = 0; i < len; ++i) { 184 | // consgetc() 会阻塞式的等待读取一个 char 185 | int c = consgetc(); 186 | str[i] = c; 187 | } 188 | copyout(p->pagetable, va, str, len); 189 | return len; 190 | } 191 | 192 | 目前我们只支持标准输入stdin的输入(对应fd = STDIN)。 -------------------------------------------------------------------------------- /source/chapter5/4exercise.rst: -------------------------------------------------------------------------------- 1 | chapter5练习 2 | ============================================== 3 | 4 | - 本节难度: **看似唬人,其实就那样** 5 | 6 | 本章任务 7 | ---------------------------------------- 8 | - ``make test BASE=1`` 执行 usershell,然后运行 ``ch5b_usertest``。 9 | - merge ch4 的修改,运行 ``ch5_mergetest`` 检查 merge 是否正确。 10 | - 结合文档和代码理解 fork, exec, wait 的逻辑。结合课堂内容回答本章问答问题(注意第二问为选做)。 11 | - 理解框架的调度机制,尤其要搞明白时钟中断的处理机制以及 yield 之后下一个进程的选择。在次基础上,完成本节的编程作业(2)stride 调度算法。 12 | - 完成本章编程作业。 13 | - 最终,完成实验报告并 push 你的 ch5 分支到远程仓库。 14 | 15 | 编程作业 16 | --------------------------------------------- 17 | 18 | 进程创建 19 | +++++++++++++++++++++++++++++++++++++++++++++ 20 | 21 | 大家一定好奇过为啥进程创建要用 fork + execve 这么一个奇怪的系统调用,就不能直接搞一个新进程吗?思而不学则殆,我们就来试一试!这章的编程练习请大家实现一个完全 DIY 的系统调用 spawn,用以创建一个新进程。 22 | 23 | spawn 系统调用定义( `标准spawn看这里 `_ ): 24 | 25 | - syscall ID: 400 26 | - C 接口: ``int spawn(char *filename)`` 27 | - 功能:相当于 fork + exec,新建子进程并执行目标程序。 28 | - 说明:成功返回子进程id,否则返回 -1。 29 | - 可能的错误: 30 | - 无效的文件名。 31 | - 进程池满/内存不足等资源错误。 32 | 33 | 实现完成之后,你应该能通过 ch5_spawn* 对应的所有测例,在 shell 中执行 ch5_usertest 来执行所有测试,应当发现除了setprio相关的测例均正确。 34 | 35 | tips: 36 | 37 | - 注意 fork 的执行流,新进程 context 的 ra 和 sp 与父进程不同。所以你不能在内核中通过 fork 和 exec 的简单组合实现 spawn。 38 | - 在 spawn 中不应该有任何形式的内存拷贝。 39 | 40 | stride 调度算法 41 | +++++++++++++++++++++++++++++++++++++++++ 42 | 43 | lab3中我们引入了任务调度的概念,可以在不同任务之间切换,目前我们实现的调度算法十分简单,存在一些问题且不存在优先级。现在我们要为我们的 os 实现一种带优先级的调度算法:stide 调度算法。 44 | 45 | 算法描述如下: 46 | 47 | (1) 为每个进程设置一个当前 stride,表示该进程当前已经运行的“长度”。另外设置其对应的 pass 值(只与进程的优先权有关系),表示对应进程在调度后,stride 需要进行的累加值。 48 | 49 | (2) 每次需要调度时,从当前 runnable 态的进程中选择 stride 最小的进程调度。对于获得调度的进程 P,将对应的 stride 加上其对应的步长 pass。 50 | 51 | (3) 一个时间片后,回到上一步骤,重新调度当前 stride 最小的进程。 52 | 53 | 可以证明,如果令 P.pass = BigStride / P.priority 其中 P.pass 为进程的 pass 值,P.priority 表示进程的优先权(大于 1),而 BigStride 表示一个预先定义的大常数,则该调度方案为每个进程分配的时间将与其优先级成正比。证明过程我们在这里略去,有兴趣的同学可以在网上查找相关资料。 54 | 55 | 其他实验细节: 56 | 57 | - stride 调度要求进程优先级 :math:`\geq 2`,所以设定进程优先级 :math:`\leq 1` 会导致错误。 58 | - 进程初始 stride 设置为 0 即可。 59 | - 进程初始优先级设置为 16。 60 | 61 | 实验首先要求新增 syscall ``sys_set_priority``: 62 | 63 | * 功能描述:设定进程优先级 64 | * syscall ID: 140 65 | * 功能:设定进程优先级。 66 | * C 接口:`int setpriority(long long prio);` 67 | * 说明:设定自身进程优先级,只要 prio 在 [2, isize_max] 就成功,返回 prio,否则返回 -1。 68 | * 针对测例 69 | * `ch5_setprio` 70 | 71 | 实现 sys_set_priority 之后,你可以通过 ``make test CHAPTER=5`` 来进行测试。 72 | 73 | 完成之后你需要调整框架的代码调度机制,是的可以设置不同进程优先级之后可以按照 stride 算法进行调度。实现正确后,代码应该能够通过用户测例 ch3t_stride*。使用 ``make test CHAPTER=5t`` 来测试测试你的实现是否正确,如果正确,ch3t_stride[x] 最终输出的 priority 和 exitcode 应该大致成正比,由于我们的时间片比较粗糙,qemu 的模拟也不是十分准确,我们最终的 CI 测试会允许最大 30% 的误差。 74 | 75 | 实现 tips: 76 | 77 | - 你应该给 proc 结构体加入新的字段来支持优先级。 78 | - 我们的测例运行时间不很长,不要求处理 stride 的溢出(详见问答作业,当然处理了更好)。 79 | - 为了减少整数除的误差,BIG_STRIDE 一般需要很大,但测例中的优先级都是 2 的整数次幂,结合第二点,BIG_STRIDE不需要太大,65536 是一个不错的数字。 80 | - 用户态的 printf 支持了行缓冲,所以如果你想要增加用户程序的输出,记得换行。 81 | - stride 算法要找到 stride 最小的进程,使用优先级队列是效率不错的办法,但是我们的实验测例很简单,所以效率完全不是问题。事实上,我很推荐使用暴力扫一遍的办法找最小值。 82 | - 注意设置进程的初始优先级。 83 | 84 | 85 | 问答作业 86 | -------------------------------------------- 87 | stride 算法深入 88 | 89 | stride 算法原理非常简单,但是有一个比较大的问题。例如两个 pass = 10 的进程,使用 8bit 无符号整形储存 stride, p1.stride = 255, p2.stride = 250,在 p2 执行一个时间片后,理论上下一次应该 p1 执行。 90 | 91 | - 实际情况是轮到 p1 执行吗?为什么? 92 | 93 | 我们之前要求进程优先级 >= 2 其实就是为了解决这个问题。可以证明,**在不考虑溢出的情况下**, 在进程优先级全部 >= 2 的情况下,如果严格按照算法执行,那么 STRIDE_MAX – STRIDE_MIN <= BigStride / 2。 94 | 95 | - 为什么?尝试简单说明(传达思想即可,不要求严格证明)。 96 | 97 | 已知以上结论,**在考虑溢出的情况下**,假设我们通过逐个比较得到 Stride 最小的进程,请设计一个合适的比较函数,用来正确比较两个 Stride 的真正大小: 98 | 99 | .. code-block:: c 100 | 101 | typedef unsigned long long Stride_t; 102 | const Stride_t BIG_STRIDE = 0xffffffffffffffffULL; 103 | int cmp(Stride_t a, Stride_t b) { 104 | // YOUR CODE HERE 105 | // return 1 if a > b 106 | // return -1 if a < b 107 | // return 0 if a == b 108 | } 109 | 110 | 111 | 例子:假设使用 8 bits 储存 stride, BigStride = 255。那么: 112 | 113 | * `cmp(125, 255) == 1` 114 | 115 | * `cmp(129, 255) == -1` 116 | 117 | 报告要求 118 | --------------------------------------- 119 | 120 | 注意目录要求,报告命名 ``lab3.md``,位于 ``reports`` 目录下。 后续实验同理。 121 | 122 | - 注明姓名学号。 123 | - 完成 ch5 问答作业。 124 | - [可选,不占分]你对本次实验设计及难度的看法。 125 | -------------------------------------------------------------------------------- /source/chapter5/index.rst: -------------------------------------------------------------------------------- 1 | 第五章:进程及进程管理 2 | ============================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 5 6 | 7 | 0intro 8 | 1process 9 | 2core-data-structures 10 | 3shell-and-binloader 11 | 4exercise 12 | 13 | -------------------------------------------------------------------------------- /source/chapter6/0intro.rst: -------------------------------------------------------------------------------- 1 | 引言 2 | ========================================= 3 | 4 | 本章导读 5 | ----------------------------------------- 6 | 7 | 文件的最早起源于我们需要把数据持久保存在 **持久存储设备** 上的需求。 8 | 9 | 大家不要被 **持久存储设备** 这个词给吓住了,这就是指计算机远古时代的卡片、纸带、磁芯、磁鼓,和现在还在使用的磁带、磁盘、硬盘,还有近期逐渐普及的U盘、闪存、固态硬盘 (SSD, Solid-State Drive)等存储设备。我们可以把这些设备叫做 **外存** 。在此之前我们仅使用一种存储,也就是内存(或称 RAM)。相比内存,持久存储设备的读写速度较慢,容量较大,但内存掉电后信息会丢失,外存在掉电之后并不会丢失数据。因此,将需要持久保存的数据从内存写入到外存,或是从外存读入到内存是应用和操作系统必不可少的一种需求。 10 | 11 | 12 | .. note:: 13 | 14 | 文件系统在UNIX操作系统有着特殊的地位,根据史料《UNIX: A History and a Memoir》记载,1969年,Ken Thompson(Unix的作者)在贝尔实验室比较闲,写了PDP-7计算机的磁盘调度算法来提高磁盘的吞吐量。为了测试这个算法,他本来想写一个批量读写数据的测试程序。但写着写着,他在某一时刻发现,这个测试程序再扩展一下,就是一个文件系统了,再再扩展一下,就是一个操作系统了。他的自觉告诉他,他离实现一个操作系统仅有 **三周之遥** 。一周:写代码编辑器;一周:写汇编器;一周写shell程序,在写这些程序的同时,需要添加操作系统的功能(如 exec等系统调用)以支持这些应用。结果三周后,为测试磁盘调度算法性能的UNIX雏形诞生了。 15 | 16 | 17 | 本章我们将实现一个简单的文件系统 -- easyfs,能够对 **持久存储设备** (Persistent Storage) 这种 I/O 资源进行管理。对于应用访问持久存储设备的需求,内核需要新增两种文件:常规文件和目录文件,它们均以文件系统所维护的 **磁盘文件** 形式被组织并保存在持久存储设备上。 18 | 19 | 同时,由于我们进一步完善了对 **文件** 这一抽象概念的实现,我们可以更容易建立 ” **一切皆文件** “ (Everything is a file) 的UNIX的重要设计哲学。我们可扩展与应用程序执行相关的 ``exec`` 系统调用,加入对程序运行参数的支持,并进一步改进了对shell程序自身的实现,加入对重定向符号 ``>`` 、 ``<`` 的识别和处理。这样我们也可以像UNIX中的shell程序一样,基于文件机制实现灵活的I/O重定位和管道操作,更加灵活地把应用程序组合在一起实现复杂功能。 20 | 21 | 实践体验 22 | ----------------------------------------- 23 | 24 | 获取本章代码: 25 | 26 | .. code-block:: console 27 | 28 | $ git checkout ch6 29 | 30 | 在 qemu 模拟器上运行本章代码: 31 | 32 | .. code-block:: console 33 | 34 | $ make test BASE=1 35 | >> ch6b_usertest 36 | 37 | .. code-block:: 38 | 39 | >> ch6b_filetest_simple 40 | file_test passed! 41 | Shell: Process 2 exited with code 0 42 | >> 43 | 44 | 它会将 ``Hello, world!`` 输出到另一个文件 ``filea`` ,并读取里面的内容确认输出正确。我们也可以通过命令行工具 ``ch6b_cat`` 来查看 ``filea`` 中的内容: 45 | 46 | .. code-block:: 47 | 48 | >> ch6b_cat 49 | Hello, world! 50 | Shell: Process 2 exited with code 0 51 | >> 52 | 53 | 本章代码树 54 | ----------------------------------------- 55 | 56 | .. code-block:: bash 57 | 58 | . 59 | ├── bootloader 60 | │ └── rustsbi-qemu.bin 61 | ├── LICENSE 62 | ├── Makefile 63 | ├── nfs (新增,辅助程序,要来将 .bin 打包为 os 可以识别的文件镜像) 64 | │ ├── fs.c 65 | │ ├── fs.h 66 | │ ├── Makefile 67 | │ └── types.h 68 | ├── os 69 | │ ├── bio.c (新增,IO buffer 的实现) 70 | │ ├── bio.h 71 | │ ├── console.c 72 | │ ├── console.h 73 | │ ├── const.h 74 | │ ├── defs.h 75 | │ ├── entry.S 76 | │ ├── fcntl.h (新增,文件相关的一些抽象) 77 | │ ├── file.c (更加完成的文件操作) 78 | │ ├── file.h (更加完成的文件定义) 79 | │ ├── fs.c (新增,文件系统实际逻辑) 80 | │ ├── fs.h 81 | │ ├── kalloc.c 82 | │ ├── kalloc.h 83 | │ ├── kernel.ld 84 | │ ├── kernelvec.S 85 | │ ├── link_app.S 86 | │ ├── loader.c 87 | │ ├── loader.h 88 | │ ├── log.h 89 | │ ├── main.c 90 | │ ├── plic.c (新增,用来处理磁盘中断) 91 | │ ├── plic.h (新增,用来处理磁盘中断) 92 | │ ├── printf.c 93 | │ ├── printf.h 94 | │ ├── proc.c 95 | │ ├── proc.h 96 | │ ├── riscv.h 97 | │ ├── sbi.c 98 | │ ├── sbi.h 99 | │ ├── string.c 100 | │ ├── string.h 101 | │ ├── switch.S 102 | │ ├── syscall.c 103 | │ ├── syscall.h 104 | │ ├── syscall_ids.h 105 | │ ├── timer.c 106 | │ ├── timer.h 107 | │ ├── trampoline.S 108 | │ ├── trap.c 109 | │ ├── trap.h 110 | │ ├── types.h 111 | │ ├── virtio_disk.c (新增,用来处理磁盘中断) 112 | │ ├── virtio.h (新增,用来处理磁盘中断) 113 | │ ├── vm.c 114 | │ └── vm.h 115 | ├── README.md 116 | ├── scripts 117 | │ └── initproc.py (弱化的 pack.py,仅仅用来插入 INIT_PROC 符号) 118 | └── user 119 | 120 | 本章代码导读 121 | ----------------------------------------------------- 122 | 123 | 本章涉及的代码量相对较多,且与进程执行相关的管理还有直接的关系。其实我们是参考经典的UNIX基于索引的文件系统,设计了一个简化的有一级目录并支持创建/打开/读写/关闭文件一系列操作的文件系统,也就是说本章。本章采用的文件系统和ext4文件系统比较类似。其中也涉及到了inode这个概念。进入本章之后,我们的测例文件一开始是存放在我们生成的“磁盘”上的,需要我们实现磁盘的读写来进行操作了。我们实现了一个简单的 nfs 文件系统,具体的结构将在下面的章节中说明。大家可以看一看我们本章对 makefile 文件的改动. 124 | 125 | .. code-block:: Makefile 126 | 127 | QEMU = qemu-system-riscv64 128 | QEMUOPTS = \ 129 | -nographic \ 130 | -smp $(CPUS) \ 131 | -machine virt \ 132 | -bios $(BOOTLOADER) \ 133 | -kernel kernel \ 134 | + -drive file=$(U)/fs.img,if=none,format=raw,id=x0 \ # 以 user/fs.img 作为磁盘镜像 135 | + -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 # 虚拟 virtio 磁盘设备 136 | 137 | 我们OS的读写文件操作均在内核态进行,由于不确定读写磁盘的结束时间,这意味着我们需要新的中断方式——外部中断来提醒OS读写结束了。而要在内核态引入中断意味着我们不得不短暂开启在内核态的嵌套中断。一旦OS打开了文件,那么我们就可以获得文件对应的fd了(实际上lab6中我们做了类似的事情),就可以使用sys_write/sys_read对文件进行读写操作。 138 | -------------------------------------------------------------------------------- /source/chapter6/1fs-interface.rst: -------------------------------------------------------------------------------- 1 | 文件系统接口 2 | ================================================= 3 | 4 | 本节导读 5 | ------------------------------------------------- 6 | 7 | 本节我们首先以 Linux 上的常规文件和目录为例,站在访问文件的应用的角度,介绍文件中值得注意的地方及文件使用方法。由于 Linux 上的文件系统模型还是比较复杂,在我们的内核实现中对它进行了很大程度的简化,我们会对简化的具体情形进行介绍。最后,我们介绍我们内核上应用的开发者应该如何使用我们简化后的文件系统和一些相关知识。 8 | 9 | 文件和目录 10 | ------------------------------------------------- 11 | 12 | 常规文件 13 | +++++++++++++++++++++++++++++++++++++++++++++++++ 14 | 15 | 在操作系统的用户看来,常规文件是保存在持久存储设备上的一个字节序列,每个常规文件都有一个 **文件名** (Filename) ,用户需要通过它来区分不同的常规文件。方便起见,在下面的描述中,“文件”有可能指的是常规文件、目录,也可能是之前提到的若干种进程可以读写的 标准输出、标准输入、管道等I/O 资源,请读者自行根据上下文判断取哪种含义。 16 | 17 | 在 Linux 系统上, ``stat`` 工具可以获取文件的一些信息。下面以我们项目中的一个源代码文件 ``os/main.c`` 为例: 18 | 19 | .. code-block:: console 20 | 21 | $ stat os/main.c 22 | 23 | File: os/main.c 24 | Size: 491 Blocks: 8 IO Block: 4096 regular file 25 | Device: 805h/2053d Inode: 4726542 Links: 1 26 | Access: (0664/-rw-rw-r--) Uid: ( 1000/deathwish) Gid: ( 1000/deathwish) 27 | Access: 2021-09-08 17:52:06.915389371 +0800 28 | Modify: 2021-09-08 17:52:06.127425836 +0800 29 | Change: 2021-09-08 17:52:06.127425836 +0800 30 | Birth: - 31 | 32 | ``stat`` 工具展示了 ``main.c`` 的如下信息: 33 | 34 | - File 表明它的文件名为 ``main.c`` 。 35 | - Size 表明它的字节大小为 491 字节。 36 | - Blocks 表明它占据 8 个 **块** (Block) 来存储。在文件系统中,文件的数据以块为单位进行存储,在 IO Block 可以看出在 Ubuntu 系统中每个块的大小为 4096 字节。 37 | - regular file 表明这个文件是一个常规文件。事实上,其他类型的文件也可以通过文件名来进行访问。 38 | - 当文件是一个特殊文件(如块设备文件或者字符设备文件的时候),Device 将指出该特殊文件的 major/minor ID 。对于一个常规文件,我们无需关心它。 39 | - Inode 表示文件的底层编号。在文件系统的底层实现中,并不是直接通过文件名来索引文件,而是首先需要将文件名转化为文件的底层编号,再根据这个编号去索引文件。然而,用户无需关心这一信息。 40 | - Links 给出文件的硬链接数。同一个文件系统中如果两个文件(目录也是文件)具有相同的inode号码,那么就称它们是“硬链接”关系。这样links的值其实是一个文件的不同文件名的数量。(本章的练习需要你在文件系统中实现硬链接!) 41 | - Uid 给出该文件的所属的用户 ID , Gid 给出该文件所属的用户组 ID 。Access 的其中一种表示是一个长度为 10 的字符串(这里是 ``-rw-r--r--`` ),其中第 1 位给出该文件的类型,这个文件是一个常规文件,因此这第 1 位为 ``-`` 。后面的 9 位可以分为三组,分别表示该文件的所有者/在该文件所属的用户组内的其他用户以及剩下的所有用户能够读取/写入/将该文件作为一个可执行文件来执行。 42 | - Access/Modify 分别给出该文件的最近一次访问/最近一次修改时间。 43 | 44 | 用户常常通过文件的 **拓展名** (Filename extension) 来推断该文件的用途,如 ``main.c`` 的拓展名是 ``.c`` ,我们由此知道它是一个 C 代码文件。但从内核的角度来看,它会将所有文件无差别的看成一个字节序列,文件内容的结构和含义则是交给对应的应用进行解析。 45 | 46 | 目录 47 | +++++++++++++++++++++++++++++++++++++++++++++++++ 48 | 49 | 最早的文件系统仅仅通过文件名来区分文件,但是这会造成一些归档和管理上的困难。如今我们的使用习惯是将文件根据功能、属性的不同分类归档到不同层级的目录之下。这样我们就很容易逐级找到想要的文件。结合用户和用户组的概念,目录的存在也使得权限控制更加容易,只需要对于目录进行设置就可以间接设置用户/用户组对该目录下所有文件的访问权限,这使得操作系统能够更加安全的支持多用户。 50 | 51 | 同样可以通过 ``stat`` 工具获取目录的一些信息: 52 | 53 | .. code-block:: console 54 | 55 | $ stat os 56 | 57 | File: os/main.c 58 | Size: 491 Blocks: 8 IO Block: 4096 regular file 59 | Device: 805h/2053d Inode: 4726542 Links: 1 60 | Access: (0664/-rw-rw-r--) Uid: ( 1000/deathwish) Gid: ( 1000/deathwish) 61 | Access: 2021-09-08 17:52:06.915389371 +0800 62 | Modify: 2021-09-08 17:52:06.127425836 +0800 63 | Change: 2021-09-08 17:52:06.127425836 +0800 64 | Birth: - 65 | 66 | directory 表明 ``os`` 是一个目录,从 Access 字符串的首位 ``d`` 也可以看出这一点。对于目录而言, Access 的 ``rwx`` 含义有所不同: 67 | 68 | - ``r`` 表示是否允许获取该目录下有哪些文件和子目录; 69 | - ``w`` 表示是否允许在该目录下创建/删除文件和子目录; 70 | - ``x`` 表示是否允许“通过”该目录。 71 | 72 | Blocks 给出 ``os`` 目录也占用 8 个块进行存储。实际上目录也可以看作一种常规文件,它也有属于自己的底层编号,它的内容中保存着若干 **目录项** (Dirent, Directory Entry) ,可以看成一组映射,根据它下面的文件或子目录的文件名或目录名能够查到文件和子目录在文件系统中的底层编号,即 Inode 编号。但是与常规文件不同的是,用户无法 **直接** 修改目录的内容,只能通过创建/删除它下面的文件或子目录才能间接做到这一点。 73 | 74 | 有了目录之后,我们就可以将所有的文件和目录组织为一种被称为 **目录树** (Directory Tree) 的有根树结构(不考虑软链接)。树中的每个节点都是一个文件或目录,一个目录下面的所有的文件和子目录都是它的孩子。可以看出所有的文件都是目录树的叶子节点。目录树的根节点也是一个目录,它被称为 **根目录** (Root Directory)。目录树中的每个目录和文件都可以用它的 **绝对路径** (Absolute Path) 来进行索引,该绝对路径是目录树上的根节点到待索引的目录和文件所在的节点之间自上而下的路径上的所有节点的文件或目录名两两之间加上路径分隔符拼接得到的。例如,在 Linux 上,根目录的绝对路径是 ``/`` ,路径分隔符也是 ``/`` ,因此: 75 | 76 | - ``main.c`` 的绝对路径是 ``/home/oslab/workspace/UCORE/uCore-Tutorial-v2/os/main.c`` ; 77 | - ``os`` 目录的绝对路径则是 ``/home/oslab/workspace/UCORE/uCore-Tutorial-v2/os`` 。 78 | 79 | 上面的绝对路径因具体环境而异。 80 | 81 | 一般情况下,绝对路径都很长,用起来颇为不便。而且,在日常使用中,我们通常固定在一个工作目录下而不会频繁切换目录。因此更为常用的是 **相对路径** (Relative Path) 而非绝对路径。每个进程都会记录自己当前所在的工作目录,当它在索引文件或目录的时候,如果传给它的路径并未以 ``/`` 开头则会被内核认为是一个相对于进程当前工作目录的相对路径,这个路径会被拼接在进程当前路径的后面组成一个绝对路径,实际索引的是这个绝对路径对应的文件或目录。其中, ``./`` 表示当前目录,而 ``../`` 表示当前目录的父目录,这在通过相对路径进行索引的时候非常实用。在使用终端的时候, ``pwd`` 工具可以打印终端进程当前所在的目录,而通过 ``cd`` 可以切换终端进程的工作目录。 82 | 83 | 一旦引入目录之后,我们就不再单纯的通过文件名来索引文件,而是通过路径(绝对或相对)进行索引。在文件系统的底层实现中,也是对应的先将路径转化为一个文件或目录的底层编号,然后再通过这个编号具体索引文件或目录。将路径转化为底层编号的过程是逐级进行的,对于绝对路径的情况,需要从根目录出发,每次根据当前目录底层编号获取到它的内容,根据下一级子目录的目录名查到该子目录的底层编号,然后从该子目录继续向下遍历,依此类推。在这个过程目录的权限控制位将会起到保护作用,阻止无权限用户进行访问。 84 | 85 | .. note:: 86 | 87 | **目录是否有必要存在** 88 | 89 | 基于路径的索引难以并行或分布式化,因为我们总是需要查到一级目录的底层编号才能查到下一级,这是一个天然串行的过程。在一些性能需求极高的环境中,可以考虑弱化目录的权限控制职能,将目录树结构扁平化,将文件系统的磁盘布局变为类键值对存储。 90 | 91 | 文件系统 92 | +++++++++++++++++++++++++++++++++++++++++++++++++ 93 | 94 | 常规文件和目录都是实际保存在持久存储设备中的。持久存储设备仅支持以扇区为单位的随机读写,这和上面介绍的通过路径即可索引到文件并进行读写的用户视角有很大的不同。负责中间转换的便是 **文件系统** (File System) 。具体而言,文件系统负责将逻辑上的目录树结构(包括其中每个文件或目录的数据和其他信息)映射到持久存储设备上,决定设备上的每个扇区各应存储哪些内容。反过来,文件系统也可以从持久存储设备还原出逻辑上的目录树结构。 95 | 96 | 文件系统有很多种不同的实现,每一种都能将同一个逻辑上目录树结构转化为一个不同的持久存储设备上的扇区布局。最著名的文件系统有 Windows 上的 FAT/NTFS 和 Linux 上的 ext3/ext4 等。 97 | 98 | 在一个计算机系统中,可以同时包含多个持久存储设备,它们上面的数据可能是以不同文件系统格式存储的。为了能够对它们进行统一管理,在内核中有一层 **虚拟文件系统** (VFS, Virtual File System) ,它规定了逻辑上目录树结构的通用格式及相关操作的抽象接口,只要不同的底层文件系统均实现虚拟文件系统要求的那些抽象接口,再加上 **挂载** (Mount) 等方式,这些持久存储设备上的不同文件系统便可以用一个统一的逻辑目录树结构一并进行管理。 99 | 100 | .. _fs-simplification: 101 | 102 | 简易文件与目录抽象 103 | ------------------------------------------------- 104 | 105 | 106 | 我们的内核实现对于目录树结构进行了很大程度上的简化,这样做的目的是为了能够完整的展示文件系统的工作原理,但代码量又不至于太多。我们进行的简化如下: 107 | 108 | - 扁平化:仅存在根目录 ``/`` 一个目录,剩下所有的文件都放在根目录内。在索引一个文件的时候,我们直接使用文件的文件名而不是它含有 ``/`` 的绝对路径。 109 | - 权限控制:我们不设置用户和用户组概念,全程只有单用户。同时根目录和其他文件也都没有权限控制位,即完全不限制文件的访问方式,不会区分文件是否可执行。 110 | - 不记录文件访问/修改的任何时间戳。 111 | - 不支持软硬链接。 112 | - 除了下面即将介绍的系统调用之外,其他的很多文件系统相关系统调用均未实现。 113 | 114 | 打开与读写文件的系统调用 115 | -------------------------------------------------- 116 | 117 | .. _sys-open: 118 | 119 | 文件打开 120 | ++++++++++++++++++++++++++++++++++++++++++++++++++ 121 | 122 | 在读写一个常规文件之前,应用首先需要通过内核提供的 ``sys_open`` 系统调用让该文件在进程的文件描述符表中占一项,并得到操作系统的返回值--文件描述符,即文件关联的表项在文件描述表中的索引值: 123 | 124 | .. code-block:: c 125 | 126 | /// 功能:打开一个常规文件,并返回可以访问它的文件描述符。 127 | /// 参数:path 描述要打开的文件的文件名(简单起见,文件系统不需要支持目录,所有的文件都放在根目录 / 下), 128 | /// flags 描述打开文件的标志,具体含义下面给出。 129 | /// 返回值:如果出现了错误则返回 -1,否则返回打开常规文件的文件描述符。可能的错误原因是:文件不存在。 130 | /// syscall ID:56 131 | int open(int dirfd, char* path, unsigned int flags, unsigned int mode); 132 | 133 | 目前我们的内核支持以下几种标志(多种不同标志可能共存): 134 | 135 | - 如果 ``flags`` 为 0,则表示以只读模式 *RDONLY* 打开; 136 | - 如果 ``flags`` 第 0 位被设置(0x001),表示以只写模式 *WRONLY* 打开; 137 | - 如果 ``flags`` 第 1 位被设置(0x002),表示既可读又可写 *RDWR* ; 138 | - 如果 ``flags`` 第 9 位被设置(0x200),表示允许创建文件 *CREATE* ,在找不到该文件的时候应创建文件;如果该文件已经存在则应该将该文件的大小归零; 139 | - 如果 ``flags`` 第 10 位被设置(0x400),则在打开文件的时候应该清空文件的内容并将该文件的大小归零,也即 *TRUNC* 。我们本章不涉及这个flags。 140 | 141 | 注意 ``flags`` 里面的权限设置只能控制进程对本次打开的文件的访问。一般情况下,在打开文件的时候首先需要经过文件系统的权限检查,比如一个文件自身不允许写入,那么进程自然也就不能以 *WRONLY* 或 *RDWR* 标志打开文件。但在我们简化版的文件系统中文件不进行权限设置,这一步就可以绕过。 142 | 143 | 144 | 文件的顺序读写 145 | ++++++++++++++++++++++++++++++++++++++++++++++++++ 146 | 147 | 在打开一个文件获得其fd之后,我们就可以用之前的 ``sys_read/sys_write`` 两个系统调用来对它进行读写了。需要注意的是,常规文件的读写模式和之前介绍过的几种文件有所不同。标准输入输出和匿名管道都属于一种流式读写,而常规文件则是顺序读写和随机读写的结合。由于常规文件可以看成一段字节序列,我们应该能够随意读写它的任一段区间的数据,即随机读写。然而用户仅仅通过 ``sys_read/sys_write`` 两个系统调用不能做到这一点。大家应该使用C时应该知道,读写文件都是有一个偏移量的,即下一次读写的起始位置是由上一次读写的结束位置决定的。我们可以使用lseek函数来改变这个偏移的位置(本章不需实现)。顺带一提,在文件系统的底层实现中都是对文件进行随机读写的。 148 | 149 | 150 | -------------------------------------------------------------------------------- /source/chapter6/3disk-based-loader: -------------------------------------------------------------------------------- 1 | 基于磁盘的 OS 2 | ================================================ 3 | 4 | 本节导读 5 | ------------------------------------------------ 6 | 在有了对磁盘的控制能力后,我们的 os 也随之发生了一系列的改变,包括: 7 | 8 | - 从磁盘加载文件(之前一直通过奇怪的操作打包进内存) 9 | - 更加完善的文件管理 10 | - 支持命令行参数 11 | - 支持 IO 重定向 12 | 13 | 这一节我们介绍前两点,下一节介绍后两点的实现。 14 | 15 | 从磁盘加载文件 16 | ------------------------------------------------ 17 | 18 | 之前,我们都通过 pack.py 和 kernelld.py 将用户软件的镜像打包进内存。但内存并不是持久化的存储介质,一旦掉电就会失效,因此把用户程序存储在磁盘上,有需要的时候从磁盘加载才是科学的方式,我们来看看要办到这一点需要那些修改。 19 | 20 | 首先,我们不再需要 pack.py kernelld.py 等脚本,仅仅保留了一个为了测试方便的 initproc.py,这个脚本的唯一作用就是插入 INIT_PROC 这个 symbol。 21 | 22 | 接下来,os 主要的修改就主要集中在 bin_loader 这个函数了,: 23 | 24 | .. code-block:: c 25 | 26 | // 首先,不在需要传入 start, end 只需要传入对应的 inode 指针 27 | // 所有的用户程序镜像已经打包到了磁盘里,这部分之后介绍 28 | int bin_loader(struct inode *ip, struct proc *p) 29 | { 30 | ivalid(ip); 31 | void *page; 32 | // 不需要 pa 相关的东西,其实 start = 文件开头,length = 文件大小 33 | uint64 length = ip->size; 34 | // va 相关设定不变 35 | uint64 va_start = BASE_ADDRESS; 36 | uint64 va_end = PGROUNDUP(BASE_ADDRESS + length); 37 | for (uint64 va = va_start, off = 0; va < va_end; va += PGSIZE, off += PAGE_SIZE) { 38 | page = kalloc(); 39 | // 之前的 memmove 变为了 readi,也就是从磁盘读取 40 | readi(ip, 0, (uint64)page, off, PAGE_SIZE); 41 | // 由于 kalloc 会写入垃圾数据,不对其的部分还是需要手动清空 42 | if (off + PAGE_SIZE > length) { 43 | memset(page + (length - off), 0, PAGE_SIZE - (length - off)); 44 | } 45 | mappages(p->pagetable, va, PGSIZE, (uint64)page, PTE_U | PTE_R | PTE_W | PTE_X); 46 | } 47 | // 其余部分不变 48 | // map stack 49 | // set trapframe 50 | } 51 | 52 | 那么,用户程序是如何被打包成一个遵循上一节所说设定的磁盘镜像的呢?这就是 ``nfs/fs.c`` 的功劳了。我们可以使用 ``make -C nfs`` 来得到一个 fs.img 的镜像,对应的 ``nfs/Makefile`` 如下: 53 | 54 | .. code-block:: Makefile 55 | 56 | FS_FUSE := fs 57 | $(FS_FUSE): fs.c fs.h types.h 58 | 59 | fs.img: $(FS_FUSE) 60 | ./$(FS_FUSE) $@ $(wildcard $(U)/$(USER_BIN_DIR)/*) 61 | 62 | 可以看到 fs.c 会被编译为可执行程序 fs, 使用格式如下: 63 | 64 | .. code-block:: console 65 | 66 | fs 输出镜像名称 输入文件1 输入文件2 输入文件3 ... 67 | 68 | Makfile 中,我们把 user 产生的所有 bin 文件作为输入文件。接下来我们简要解析 fs.c 的具体逻辑,同学们不需要完整的理解这段程序的逻辑,但想要正确的完成 lab7 的实验,必须对 fs.c 进行对应的修改。在 exercise 章节会有一定提示。 69 | 70 | .. code-block:: c 71 | 72 | // 一些全局常量定义 73 | int NINODES = 200; // 文件系统设定的 inode 数量 74 | int nbitmap = FSSIZE / (BSIZE * 8) + 1; // bitmap block 的数量 75 | int ninodeblocks = NINODES / IPB + 1; // inode block 的数量 76 | int nmeta; // meta 数据块的数量(含义下方解释) 77 | int nblocks; 78 | 79 | // 一些全局变量定义 80 | int fsfd; // 输出镜像文件的 fd 81 | struct superblock sb; // 超级块 82 | char zeroes[BSIZE]; // BSIZE 大小的空 buf,用来清空某一个磁盘块 83 | uint freeinode = 1; // 表示还空闲的 inode,每使用一个 inode 需要 +1 84 | uint freeblock; // 表示还空闲的 block,每使用一个 block 需要 +1 85 | 86 | int main(int argc, char *argv[]) 87 | { 88 | int i, cc, fd; 89 | uint rootino, inum, off; 90 | struct dirent de; 91 | char buf[BSIZE]; 92 | struct dinode din; 93 | // 至少需要输入镜像的名称 94 | if (argc < 2) { 95 | fprintf(stderr, "Usage: mkfs fs.img files...\n"); 96 | exit(1); 97 | } 98 | // 创建输出文件 99 | fsfd = open(argv[1], O_RDWR | O_CREAT | O_TRUNC, 0666); 100 | 101 | // [ boot block | sb block | inode blocks | free bit map | data blocks ] 102 | // meta data 包括: boot block, superblock, inode block, free bitmap 103 | nmeta = 2 + ninodeblocks + nbitmap; 104 | // nblocks 表示存放数据剩余的块数 105 | nblocks = FSSIZE - nmeta; 106 | 107 | // superblock 初始化 108 | // xint() 的功能仅仅是转换字节顺序,大家可以忽略这个细节 109 | sb.magic = FSMAGIC; 110 | sb.size = xint(FSSIZE); 111 | sb.nblocks = xint(nblocks); 112 | sb.ninodes = xint(NINODES); 113 | sb.inodestart = xint(2); 114 | sb.bmapstart = xint(2 + ninodeblocks); 115 | 116 | // 目前还空闲的的块,目前我们只占用了 meta data 的那些块 117 | // 之后,没使用一块,freeblock 需要 +1。 118 | freeblock = nmeta; 119 | 120 | // wsect 会写输出镜像,把第 i 个块写为 buf,这里首先清空一遍 121 | for (i = 0; i < FSSIZE; i++) 122 | wsect(i, zeroes); 123 | 124 | memset(buf, 0, sizeof(buf)); 125 | memmove(buf, &sb, sizeof(sb)); 126 | // 0 号块不处理 127 | // 1 号快写为之前设定好的 superblock 128 | wsect(1, buf); 129 | 130 | // ialloc() 会分配一个空的 inode,并初始化 type,返回 inode id 131 | rootino = ialloc(T_DIR); 132 | 133 | // 从第二个参数开始都是需要打包的用户程序 134 | for (i = 2; i < argc; i++) { 135 | // 获得 basename,这是为了去掉前缀 "../user/target/bin/" 136 | char *shortname = basename(argv[i]); 137 | assert(index(shortname, '/') == 0); 138 | 139 | // 打开对应的用户文件 140 | if ((fd = open(argv[i], 0)) < 0) { 141 | perror(argv[i]); 142 | exit(1); 143 | } 144 | 145 | // 为每一个用户程序分配一个 inode 146 | inum = ialloc(T_FILE); 147 | 148 | // 为每一个用户程序分配一个根目录中的目录项 149 | bzero(&de, sizeof(de)); 150 | de.inum = xshort(inum); 151 | strncpy(de.name, shortname, DIRSIZ); 152 | // 把该目录项写入根目录的数据块 153 | // iappend 会像某一个 inode 对应的数据块后 append 数据 154 | iappend(rootino, &de, sizeof(de)); 155 | // 读取该程序的数据并写入对应 inode 的数据块 156 | while ((cc = read(fd, buf, sizeof(buf))) > 0) 157 | iappend(inum, buf, cc); 158 | 159 | close(fd); 160 | } 161 | 162 | // 更新 rootdir inode 对应的 size 数据,按数据快大小取整 163 | rinode(rootino, &din); 164 | off = xint(din.size); 165 | off = ((off / BSIZE) + 1) * BSIZE; 166 | din.size = xint(off); 167 | winode(rootino, &din); 168 | // balloc 完成 bitmap 的填写,把 [0, freeblock) 的块标记为已经使用 169 | balloc(freeblock); 170 | return 0; 171 | } 172 | 173 | 174 | 更加完善的文件管理 175 | ---------------------------------------- 176 | 177 | ch7 规范化了对于 fd 的管理,比如 0, 1, 2 不再是保留给 stdio 文件的 fd 参数,而是会在进程创建的时候给进程 3 个默认文件。为什么要这么做?这使得不需要的时候我们可以关闭 stdio 文件: 178 | 179 | .. code-block:: c 180 | 181 | // allocproc() 之后必须执行 init_stdio_files 来初始化 files 182 | int init_stdio_files(struct proc *p) 183 | { 184 | for (int i = 0; i < 3; i++) { 185 | p->files[i] = stdio_init(i); 186 | } 187 | return 0; 188 | } 189 | 190 | struct file *stdio_init(int fd) 191 | { 192 | struct file *f = filealloc(); 193 | f->type = FD_STDIO; 194 | f->ref = 1; 195 | f->readable = (fd == STDIN || fd == STDERR); 196 | f->writable = (fd == STDOUT || fd == STDERR); 197 | return f; 198 | } 199 | 200 | 同时 close 0,1,2 号文件不会失败并返回 -1,文件读写也不会直接根据 fd 判断,例如 sys_write: 201 | 202 | .. code-block:: c 203 | 204 | uint64 sys_write(int fd, uint64 va, uint64 len) 205 | { 206 | if (fd < 0 || fd > FD_BUFFER_SIZE) 207 | return -1; 208 | struct proc *p = curr_proc(); 209 | struct file *f = p->files[fd]; 210 | switch (f->type) { 211 | case FD_STDIO: 212 | return console_write(va, len); 213 | case FD_PIPE: 214 | return pipewrite(f->pipe, va, len); 215 | case FD_INODE: 216 | return inodewrite(f, va, len); 217 | default: 218 | panic("unknown file type %d\n", f->type); 219 | } 220 | } 221 | 222 | 之后我们会看到,这使得文件重定向成为了可能, // TODO -------------------------------------------------------------------------------- /source/chapter6/4exec-with-argv: -------------------------------------------------------------------------------- 1 | 命令行参数与 IO 重定向 2 | ================================================== 3 | 4 | 通过上述努力,我们可以支持文件重定向,当然这需要 usershell 做一些字符串解析的工作。但我们尴尬的发现,我们的 os 甚至还不支持带参数的 exec,也就是 ``main(int argc, char** argv)``,所以我们在 lab6 通过修改 exec 支持了这一点。 5 | 6 | -------------------------------------------------------------------------------- /source/chapter6/5exercise.rst: -------------------------------------------------------------------------------- 1 | chapter6练习 2 | ================================================ 3 | 4 | - 本节难度: **理解文件系统比较费事,编程难度适中** 5 | 6 | 本章任务 7 | ----------------------------------------------- 8 | - ``ch6b_usertest`` 9 | - merge ch6 的改动,然后再次测试 ch6_usertest。 10 | - 完成本章问答作业。 11 | - 完成本章编程作业。 12 | - 最终,完成实验报告并 push 你的 ch6 分支到远程仓库。 13 | 14 | 编程作业 15 | ------------------------------------------------- 16 | 17 | 硬链接 18 | ++++++++++++++++++++++++++++++++++++++++++++++++++ 19 | 20 | 你的电脑桌面是什么样的?放满了图标吗?反正我的 windows 是这样的。显然很少人会真的把可执行文件放到桌面上,桌面图标其实都是一些快捷方式。或者用 unix 的术语来说:软链接。为了减少工作量,我们今天来实现软链接的兄弟: `硬链接 `_ 。 21 | 22 | 硬链接要求两个不同的目录项指向同一个文件,在我们的文件系统中也就是两个不同名称目录项指向同一个磁盘块。本节要求实现三个系统调用 ``sys_linkat、sys_unlinkat、sys_stat`` 。 23 | 24 | **linkat**: 25 | 26 | - syscall ID: 37 27 | - 功能:创建一个文件的一个硬链接, `linkat标准接口 `_ 。 28 | - 接口: ``int linkat(int olddirfd, char* oldpath, int newdirfd, char* newpath, unsigned int flags)`` 29 | - 参数: 30 | - olddirfd,newdirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。 31 | - flags: 仅为了兼容性考虑,本次实验中始终为 0,可以忽略。 32 | - oldpath:原有文件路径 33 | - newpath: 新的链接文件路径。 34 | - 说明: 35 | - 为了方便,不考虑新文件路径已经存在的情况(属于未定义行为),除非链接同名文件。 36 | - 返回值:如果出现了错误则返回 -1,否则返回 0。 37 | - 可能的错误 38 | - 链接同名文件。 39 | 40 | **unlinkat**: 41 | 42 | - syscall ID: 35 43 | - 功能:取消一个文件路径到文件的链接, `unlinkat标准接口 `_ 。 44 | - 接口: ``int unlinkat(int dirfd, char* path, unsigned int flags)`` 45 | - 参数: 46 | - dirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。 47 | - flags: 仅为了兼容性考虑,本次实验中始终为 0,可以忽略。 48 | - path:文件路径。 49 | - 说明: 50 | - 需要注意 unlink 掉所有硬链接后彻底删除文件的情况。 51 | - 返回值:如果出现了错误则返回 -1,否则返回 0。 52 | - 可能的错误 53 | - 文件不存在。 54 | 55 | **fstat**: 56 | 57 | - syscall ID: 80 58 | - 功能:获取文件状态。 59 | - 接口: ``int fstat(int fd, struct Stat* st)`` 60 | - 参数: 61 | - fd: 文件描述符 62 | - st: 文件状态结构体 63 | 64 | .. code-block:: c 65 | 66 | struct Stat { 67 | uint64 dev, // 文件所在磁盘驱动号,该实现写死为 0 即可。 68 | uint64 ino, // inode 文件所在 inode 编号 69 | uint32 mode, // 文件类型 70 | uint32 nlink, // 硬链接数量,初始为1 71 | uint64 pad[7], // 无需考虑,为了兼容性设计 72 | } 73 | 74 | // 文件类型只需要考虑: 75 | #define DIR 0x040000 // directory 76 | #define FILE 0x100000 // ordinary regular file 77 | 78 | - 返回值:如果出现了错误则返回 -1,否则返回 0。 79 | - 可能的错误 80 | - fd 无效。 81 | - st 地址非法。 82 | 83 | 正确实现后,你的 os 应该能够正确运行 ch6_file* 对应的测试用例,在 shell 中执行 ch6_usertest 来执行测试。 84 | 85 | Tips 86 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 87 | 88 | - 需要给 inode 和 dinode 都增加 link 的计数,但强烈建议不要改变整个数据结构的大小,事实上,推荐你修改一个 pad。 89 | - os 和 nfs 的修改需要同步,只不过 nfs 比较简单,只需要初始化 link 计数为 1 就行(可以通过修改 ``ialloc`` 来实现)。 90 | - unlink 有删除文件的语义,如果 link 计数为 0,需要删除 inode 和对应的数据块,为此你需要正确调用 ``ivalid`` 、 ``iupdate`` 、 ``iput`` (如果测试遇到bug了不妨再看看这句话),并取消 ``iput`` 中判断条件的注释。你可能需要修改 ``iput`` 注释中的变量名(如果你的计数变量不叫 nlink)。 91 | 92 | 93 | 问答作业 94 | ---------------------------------------------------------- 95 | 96 | 1. 在我们的文件系统中,root inode起着什么作用?如果root inode中的内容损坏了,会发生什么? 97 | 98 | 报告要求 99 | ----------------------------------------------------------- 100 | 101 | 注意目录要求,报告命名 ``lab4.md`` 或 ``lab4.pdf``,位于 ``reports`` 目录下。 102 | 103 | 特别的,ch7 的问答问题要一并写入本分支的报告。 104 | 105 | 报告内容: 106 | 107 | - 注明姓名学号。 108 | - 简单总结本次实验你新添加的代码。 109 | * 完成 ch6 问答问题 110 | * (optional) 你对本次实验设计及难度的看法。 111 | -------------------------------------------------------------------------------- /source/chapter6/ch6.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter6/ch6.pptx -------------------------------------------------------------------------------- /source/chapter6/index.rst: -------------------------------------------------------------------------------- 1 | 第六章:文件系统与I/O重定向 2 | ============================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | 0intro 8 | 1fs-interface 9 | 2fs-implementation 10 | 3disk-based-loader 11 | 4exec-with-argv 12 | 5exercise 13 | -------------------------------------------------------------------------------- /source/chapter6/user-stack-cmdargs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/chapter6/user-stack-cmdargs.png -------------------------------------------------------------------------------- /source/chapter7/0intro.rst: -------------------------------------------------------------------------------- 1 | 引言 2 | ========================================= 3 | 4 | 本章导读 5 | ----------------------------------------- 6 | 7 | 在上一章中,我们引入了非常重要的进程的概念,以及与进程管理相关的 ``fork`` 、 ``exec`` 等创建新进程相关的系统调用。虽然操作系统提供新进程的动态创建和执行的服务有了很大的改进,但截止到目前为止,进程在输入和输出方面,还有不少限制。特别是进程能够进行交互的 I/O 资源还非常有限,只能接受用户在键盘上的输入,并将字符输出到屏幕上。我们一般将它们分别称为 **标准** 输入和 **标准** 输出。而且进程之间缺少信息交换的能力,这样就限制了通过进程间的合作一起完成一个大事情的能力。 8 | 9 | 其实在 **UNIX** 的早期发展历史中,也碰到了同样的问题,每个程序专注在完成一件事情上,但缺少把多个程序联合在一起完成复杂功能的机制。直到1975年UNIX v6中引入了让人眼前一亮的创新机制-- **I/O重定向** 与 **管道(pipe)** 。基于这两种机制,操作系统在不用改变应用程序的情况下,可以将一个程序的输出重新定向到另外一个程序的输入中,这样程序之间就可以进行任意的连接,并组合出各种灵活的复杂功能。 10 | 11 | 本章我们也会引入新操作系统概念 -- 管道,并进行实现。除了键盘和屏幕这样的 **标准** 输入和 **标准** 输出之外,管道其实也可以看成是一种特殊的输入和输出,而后面一章讲解的 **文件系统** 中的对持久化存储数据的抽象 **文件(file)** 也是一种存储设备的输入和输出。所以,我们可以把这三种输入输出都统一在 **文件(file)** 这个抽象之中。这也体现了在 Unix 操作系统中, ” **一切皆文件** “ (Everything is a file) 重要设计哲学。 12 | 13 | 在本章中提前引入 **文件** 这个概念,但本章不会详细讲解,只是先以最简单直白的方式对 **文件** 这个抽象进行简化的设计与实现。站在本章的操作系统的角度来看, **文件** 成为了一种需要操作系统管理的I/O资源。 14 | 15 | 为了让应用能够基于 **文件** 这个抽象接口进行I/O操作,我们就需要对 **进程** 这个概念进行扩展,让它能够管理 **文件** 这种资源。具体而言,就是要对进程控制块进行一定的扩展。为了统一表示 **标准** 输入和 **标准** 输出和管道,我们将在每个进程控制块中增加一个 **文件描述符表** ,在表中保存着多个 **文件** 记录信息。每个文件描述符是一个非负的索引值,即对应文件记录信息的条目在文件描述符表中的索引,可方便进程表示当前使用的 **标准** 输入、 **标准** 输出和管道(当然在下一章还可以表示磁盘上的一块数据)。用户进程访问文件将很简单,它只需通过文件描述符,就可以对 **文件** 进行读写,从而完成接收键盘输入,向屏幕输出,以及两个进程之间进行数据传输的操作。 16 | 17 | 本章我们的主要目的是实现进程间的通信方式。这就意味着一个进程得到的输入和输出不一定是针对标准输入输出流了(也就是fd == 0 的 stdin 和 fd == 1 的 stdout),而可能是对应的pipe的新的fd。考虑到lab7我们就需要实现一个比较完成的文件系统,在lab6中乘着引入pipe的机会我们会先实现一个文件系统(fs)的雏形。 18 | 19 | 实践体验 20 | ----------------------------------------- 21 | 22 | 获取本章代码: 23 | 24 | .. code-block:: console 25 | 26 | $ git checkout ch7 27 | 28 | 在 qemu 模拟器上运行本章代码: 29 | 30 | .. code-block:: console 31 | 32 | $ make test BASE=1 33 | # 在 shell 中执行: 34 | >> ch7b_usertest 35 | 36 | 本章代码树 37 | ----------------------------------------- 38 | 39 | .. code-block:: bash 40 | 41 | . 42 | ├── bootloader 43 | │ └── rustsbi-qemu.bin 44 | ├── LICENSE 45 | ├── Makefile 46 | ├── os 47 | │ ├── console.c 48 | │ ├── console.h 49 | │ ├── const.h 50 | │ ├── defs.h 51 | │ ├── entry.S 52 | │ ├── file.c 53 | │ ├── file.h 54 | │ ├── kalloc.c 55 | │ ├── kalloc.h 56 | │ ├── kernel.ld 57 | │ ├── kernelld.py 58 | │ ├── loader.c 59 | │ ├── loader.h 60 | │ ├── log.h 61 | │ ├── main.c 62 | │ ├── pack.py 63 | │ ├── pipe.c 64 | │ ├── printf.c 65 | │ ├── printf.h 66 | │ ├── proc.c 67 | │ ├── proc.h 68 | │ ├── riscv.h 69 | │ ├── sbi.c 70 | │ ├── sbi.h 71 | │ ├── string.c 72 | │ ├── string.h 73 | │ ├── switch.S 74 | │ ├── syscall.c 75 | │ ├── syscall.h 76 | │ ├── syscall_ids.h 77 | │ ├── timer.c 78 | │ ├── timer.h 79 | │ ├── trampoline.S 80 | │ ├── trap.c 81 | │ ├── trap.h 82 | │ ├── types.h 83 | │ ├── vm.c 84 | │ └── vm.h 85 | ├── README.md 86 | ├── scripts 87 | │ ├── kernelld.py 88 | │ └── pack.py 89 | └── user 90 | 91 | 92 | 93 | 本章代码导读 94 | ----------------------------------------------------- 95 | 96 | 本章中引入了新的几个系统调用: 97 | 98 | .. code-block:: c 99 | 100 | /// 功能:为当前进程打开一个管道。 101 | /// 参数:pipe 表示应用地址空间中的一个长度为 2 的 long 数组的起始地址,内核需要按顺序将管道读端和写端的文件描述符写入到数组中。 102 | /// 返回值:如果出现了错误则返回 -1,否则返回 0 。可能的错误原因是:传入的地址不合法。 103 | /// syscall ID:59 104 | long sys_pipe(long fd[2]); 105 | 106 | 107 | /// 功能:当前进程关闭一个文件。 108 | /// 参数:fd 表示要关闭的文件的文件描述符。 109 | /// 返回值:如果成功关闭则返回 0 ,否则返回 -1 。可能的出错原因:传入的文件描述符并不对应一个打开的文件。 110 | /// syscall ID:57 111 | long sys_close(int fd); 112 | 113 | 同时,为了支持对文件的支持,对sys_write和sys_read都有修改。本章的pipe被我们抽象成了文件的概念,因此,其对应的fd就是用于sys_write和sys_read的fd。我们的sys_close关闭文件,这本章也就是关闭管道。 114 | -------------------------------------------------------------------------------- /source/chapter7/2pipe.rst: -------------------------------------------------------------------------------- 1 | 进程通讯与 fork 2 | ============================================ 3 | 4 | fork的修改 5 | -------------------------------------------- 6 | 7 | 对fork的文件支持本来应该在chapter6引入,但是为了更好的理解管道的继承机制,我们把它放在了这个章节。 8 | fork 为什么是毒瘤呢?因为你总是要在新增加一个东西以后考虑要不要为新功能增加 fork 支持。这一章的文件就是第一个例子,那么在 fork 语境下,子进程也需要继承父进程的文件资源,也就是PCB之中的指针文件数组。我们应该如何处理呢?我们来看看 fork 在这一个 chapter 的实现: 9 | 10 | .. code-block:: c 11 | 12 | int fork() { 13 | // ... 14 | + for(int i = 3; i < FD_MAX; ++i) 15 | + if(p->files[i] != 0 && p->files[i]->type != FD_NONE) { 16 | + p->files[i]->ref++; 17 | + np->files[i] = p->files[i]; 18 | + } 19 | // ... 20 | } 21 | 22 | 可以看到创建子进程时会遍历父进程,继承其所有打开的文件,并且给指定文件的ref + 1。因为我们记录的本身就只是一个指针,只需用ref来记录一个文件还有没有进程使用。 23 | 24 | 此外,进程结束需要清理的资源除了内存之外增加了文件: 25 | 26 | .. code-block:: c 27 | 28 | void freeproc(struct proc *p) 29 | { 30 | // ... 31 | + for (int i = 3; i < FD_BUFFER_SIZE; i++) { 32 | + if (p->files[i] != NULL) { 33 | + fileclose(p->files[i]); 34 | + } 35 | + } 36 | // ... 37 | } 38 | 39 | 你会发现 exec 的实现竟然没有修改,注意 exec 仅仅重新加载进程执行的测例文件镜像,不会改变其他属性,比如文件。也就是说,fork 出的子进程打开了与父进程相同的文件,但是 exec 并不会把打开的文件刷掉,基于这一点,我们可以利用 pipe 进行进程间通信。 40 | 41 | .. code-block:: c 42 | 43 | // user/src/ch6b_pipetest 44 | 45 | char STR[] = "hello pipe!"; 46 | 47 | int main() { 48 | uint64 pipe_fd[2]; 49 | int ret = pipe(&pipe_fd); 50 | if (fork() == 0) { 51 | // 子进程,从 pipe 读,和 STR 比较。 52 | char buffer[32 + 1]; 53 | read(pipe_fd[0], buffer, 32); 54 | assert(strncmp(buffer, STR, strlen(STR) == 0); 55 | exit(0); 56 | } else { 57 | // 父进程,写 pipe 58 | write(pipe_fd[1], STR, strlen(STR)); 59 | int exit_code = 0; 60 | wait(&exit_code); 61 | assert(exit_code == 0); 62 | } 63 | return 0; 64 | } 65 | 66 | 由于 fork 会拷贝所有文件而 exec 不会改变文件,所以父子进程的fd列表一致,可以直接使用创建好的pipe进行通信。 -------------------------------------------------------------------------------- /source/chapter7/3exercise.rst: -------------------------------------------------------------------------------- 1 | chapter7练习 2 | =========================================== 3 | 4 | 5 | 编程作业 6 | ------------------------------------------- 7 | 本章没有编程作业 8 | 9 | 10 | 问答作业 11 | ------------------------------------------- 12 | 13 | 1. 举出使用 pipe 的一个实际应用的例子。 14 | 15 | tips: 16 | 17 | - 想想你平时咋使用 linux terminal 的? 18 | - 如何使用 cat 和 wc 完成一个文件的行数统计? 19 | 20 | 2. 如果需要在多个进程间互相通信,则需要为每一对进程建立一个管道,非常繁琐,请设计一个更易用的多进程通信机制。 21 | 22 | 23 | 报告要求 24 | ------------------------------- 25 | 26 | 注意本节问答题请和 ch6 一并完成,并一同交到 ch6 分支的 ``lab4.md`` 或 ``lab4.pdf``。 27 | 28 | 报告内容: 29 | 30 | - 注明姓名学号。 31 | - 完成 ch7 问答作业。 32 | - (optional) 你对本次实验设计及难度的看法。 -------------------------------------------------------------------------------- /source/chapter7/index.rst: -------------------------------------------------------------------------------- 1 | 第七章:进程间通信 2 | ============================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | 0intro 8 | 1file-descriptor 9 | 2pipe 10 | 3exercise 11 | 12 | 有团队协作能力的“迅猛龙”操作系统。 -------------------------------------------------------------------------------- /source/chapter8/0intro.rst: -------------------------------------------------------------------------------- 1 | 引言 2 | ========================================= 3 | 4 | 本章导读 5 | ----------------------------------------- 6 | 7 | 到本章开始之前,我们完成了组成应用程序执行环境的操作系统的三个重要抽象:进程、地址空间和文件, 8 | 操作系统基于处理器的时间片不断地切换进程可以实现宏观的多应用并发,但仅限于进程间。 9 | 下面我们引入线程(Thread):提高一个进程内的并发性。 10 | 11 | 为什么有了进程还需要线程呢?因为对于很多单进程应用,逻辑上存在多个可并行执行的任务, 12 | 如果没有多线程,其中一个任务被阻塞,将会引起不依赖该任务的其他任务也被阻塞。 13 | 譬如我们用 Word 时,会有一个定时自动保存功能,如果你电脑突然崩溃关机或者停电,已有的文档内容或许已被提前保存。 14 | 假设没有多线程,自动保存时由于磁盘性能导致写入较慢,可能导致整个进程被操作系统挂起, 15 | 对我们来说便是 Word 过一阵子就卡一会儿,严重影响体验。 16 | 17 | 18 | .. _term-thread-define: 19 | 20 | 线程定义 21 | ~~~~~~~~~~~~~~~~~~~~ 22 | 23 | 简单地说,线程是进程的组成部分,进程可包含1 -- n个线程,属于同一个进程的线程共享进程的资源, 24 | 比如地址空间、打开的文件等。基本的线程由线程ID、执行状态、当前指令指针 (PC)、寄存器集合和栈组成。 25 | 线程是可以被操作系统或用户态调度器独立调度(Scheduling)和分派(Dispatch)的基本单位。 26 | 27 | 在本章之前,进程是程序的基本执行实体,是程序关于某数据集合上的一次运行活动,是系统进行资源(处理器、 28 | 地址空间和文件等)分配和调度的基本单位。在有了线程后,对进程的定义也要调整了,进程是线程的资源容器, 29 | 线程成为了程序的基本执行实体。 30 | 31 | 32 | 同步互斥 33 | ~~~~~~~~~~~~~~~~~~~~~~ 34 | 35 | 在上面提到了同步互斥和数据一致性,它们的含义是什么呢?当多个线程共享同一进程的地址空间时, 36 | 每个线程都可以访问属于这个进程的数据(全局变量)。如果每个线程使用到的变量都是其他线程不会读取或者修改的话, 37 | 那么就不存在一致性问题。如果变量是只读的,多个线程读取该变量也不会有一致性问题。但是,当一个线程修改变量时, 38 | 其他线程在读取这个变量时,可能会看到一个不一致的值,这就是数据不一致性的问题。 39 | 40 | .. note:: 41 | 42 | **并发相关术语** 43 | 44 | - 共享资源(shared resource):不同的线程/进程都能访问的变量或数据结构。 45 | - 临界区(critical section):访问共享资源的一段代码。 46 | - 竞态条件(race condition):多个线程/进程都进入临界区时,都试图更新共享的数据结构,导致产生了不期望的结果。 47 | - 不确定性(indeterminate): 多个线程/进程在执行过程中出现了竞态条件,导致执行结果取决于哪些线程在何时运行, 48 | 即执行结果不确定,而开发者期望得到的是确定的结果。 49 | - 互斥(mutual exclusion):一种操作原语,能保证只有一个线程进入临界区,从而避免出现竞态,并产生确定的执行结果。 50 | - 原子性(atomic):一系列操作要么全部完成,要么一个都没执行,不会看到中间状态。在数据库领域, 51 | 具有原子性的一系列操作称为事务(transaction)。 52 | - 同步(synchronization):多个并发执行的进程/线程在一些关键点上需要互相等待,这种相互制约的等待称为进程/线程同步。 53 | - 死锁(dead lock):一个线程/进程集合里面的每个线程/进程都在等待只能由这个集合中的其他一个线程/进程 54 | (包括他自身)才能引发的事件,这种情况就是死锁。 55 | - 饥饿(hungry):指一个可运行的线程/进程尽管能继续执行,但由于操作系统的调度而被无限期地忽视,导致不能执行的情况。 56 | 57 | 在后续的章节中,会大量使用上述术语,如果现在还不够理解,没关系,随着后续的一步一步的分析和实验, 58 | 相信大家能够掌握上述术语的实际含义。 59 | 60 | 61 | 62 | 实践体验 63 | ----------------------------------------- 64 | 65 | 获取本章代码: 66 | 67 | .. code-block:: console 68 | 69 | $ git clone https://github.com/LearningOS/uCore-Tutorial-Code-2022S.git 70 | $ cd uCore-Tutorial-Code-2022S 71 | $ git checkout ch8 72 | $ git clone https://github.com/LearningOS/uCore-Tutorial-Test-2022S.git user 73 | 74 | 或者你也可以在自己原来的仓库里 fetch 它,记得更新测例仓库的代码。 75 | 76 | 在 qemu 模拟器上运行本章代码: 77 | 78 | .. code-block:: console 79 | 80 | $ make BASE=1 test 81 | 82 | 内核初始化完成之后就会进入 shell 程序,我们可以体会一下线程的创建和执行过程。在这里我们运行一下本章的测例 ``ch8b_threads`` : 83 | 84 | .. code-block:: 85 | 86 | >> ch8b_threads 87 | aaa....bbb...ccc... 88 | thread#1 exited with code 1 89 | thread#2 exited with code 2 90 | thread#3 exited with code 3 91 | threads test passed! 92 | Shell: Process 2 exited with code 0 93 | >> 94 | 95 | 它会有4个线程在执行,等前3个线程执行完毕并输出大量 a/b/c 后,主线程退出,导致整个进程退出。 96 | 97 | 此外,在本章的操作系统支持通过互斥来执行“哲学家就餐问题”这个应用程序: 98 | 99 | .. code-block:: 100 | 101 | >> ch8b_mut_phi_din 102 | Here comes 5 philosophers! 103 | Phil threads created 104 | time cost = 720 ms 105 | '-' -> THINKING; 'x' -> EATING; ' ' -> WAITING 106 | #0:-------- xxxxxxxx----------- xxxx------ xxxxxx---xxx 107 | #1:----xxxxx--- xxxxxxx----------- x----xxxxxx 108 | #2:------ xx----------x-----xxxxx------------- xxxxx 109 | #3:------xxxxxxxxx-------xxxx--------- xxxxxx--- xxxxxxxxxx 110 | #4:------- x------- xxxxxx--- xxxxx------- xxx 111 | #0:-------- xxxxxxxx----------- xxxx------ xxxxxx---xxx 112 | Shell: Process 2 exited with code 0 113 | >> 114 | 115 | 我们可以看到5个代表“哲学家”的线程通过操作系统的 **信号量** 互斥机制在进行 “THINKING”、“EATING”、“WAITING” 的日常生活。 116 | 没有哲学家由于拿不到筷子而饥饿,也没有两个哲学家同时拿到一个筷子。 117 | 118 | .. note:: 119 | 120 | **哲学家就餐问题** 121 | 122 | 计算机科学家 Dijkstra 提出并解决的哲学家就餐问题是经典的进程同步互斥问题。哲学家就餐问题描述如下: 123 | 124 | 有5个哲学家共用一张圆桌,分别坐在周围的5张椅子上,在圆桌上有5个碗和5只筷子,他们的生活方式是交替地进行思考和进餐。 125 | 平时,每个哲学家进行思考,饥饿时便试图拿起其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。 126 | 127 | 128 | 本章代码树 129 | ----------------------------------------- 130 | 131 | .. code-block:: 132 | :linenos: 133 | 134 | . 135 | ├── bootloader 136 | │ └── rustsbi-qemu.bin 137 | ├── LICENSE 138 | ├── Makefile 139 | ├── nfs 140 | │ ├── fs.c 141 | │ ├── fs.h 142 | │ ├── Makefile 143 | │ └── types.h 144 | ├── os 145 | │ ├── bio.c 146 | │ ├── bio.h 147 | │ ├── console.c 148 | │ ├── console.h 149 | │ ├── const.h 150 | │ ├── defs.h 151 | │ ├── entry.S 152 | │ ├── fcntl.h 153 | │ ├── file.c 154 | │ ├── file.h 155 | │ ├── fs.c 156 | │ ├── fs.h 157 | │ ├── kalloc.c 158 | │ ├── kalloc.h 159 | │ ├── kernel.ld 160 | │ ├── kernelld.py 161 | │ ├── kernelvec.S 162 | │ ├── loader.c(修改:更改了加载用户程序的逻辑,此时不再为进程分配用户栈) 163 | │ ├── loader.h 164 | │ ├── log.h(修改:log头中新增了线程号打印) 165 | │ ├── main.c 166 | │ ├── pipe.c 167 | │ ├── plic.c 168 | │ ├── plic.h 169 | │ ├── printf.c 170 | │ ├── printf.h 171 | │ ├── proc.c(修改:为每个线程而非进程分配栈空间和trapframe;更改进程初始化逻辑,为其分配主线程;任务调度粒度从进程改为线程;新增线程id与线程指针转换的辅助函数;新增线程分配、释放逻辑;exit由退出进程改为退出线程) 172 | │ ├── proc.h(修改:新增线程相关结构体和状态枚举;在PCB中新增线程相关变量;增改部分函数签名) 173 | │ ├── queue.c(修改:由进程专用队列改为通用队列,初始化时需指定数组地址和大小) 174 | │ ├── queue.h(修改:同 queue.c) 175 | │ ├── riscv.h 176 | │ ├── sbi.c 177 | │ ├── sbi.h 178 | │ ├── string.c 179 | │ ├── string.h 180 | │ ├── switch.S 181 | │ ├── sync.c(新增:实现了mutex、semaphore、condvar相关操作) 182 | │ ├── sync.h(新增:声明了mutex、semaphore、condvar相关操作) 183 | │ ├── syscall.c(修改:增加 sys_thread_create、sys_gettid、sys_waittid 以及三种同步互斥结构所用到的系统调用) 184 | │ ├── syscall.h(修改:同syscall.c) 185 | │ ├── syscall_ids.h(修改:为新增系统调用增加了调号号的宏定义) 186 | │ ├── timer.c 187 | │ ├── timer.h 188 | │ ├── trampoline.S 189 | │ ├── trap.c(修改:将进程trap改为线程trap,新增用户态虚存映射辅助函数uvmmap) 190 | │ ├── trap.h(修改:同trap.c) 191 | │ ├── types.h 192 | │ ├── virtio_disk.c 193 | │ ├── virtio.h 194 | │ ├── vm.c 195 | │ └── vm.h 196 | ├── README.md 197 | └── scripts 198 | └── initproc.py 199 | -------------------------------------------------------------------------------- /source/chapter8/3semaphore.rst: -------------------------------------------------------------------------------- 1 | 信号量机制 2 | ========================================= 3 | 4 | 本节导读 5 | ----------------------------------------- 6 | 7 | 在上一节中,我们介绍了互斥锁(mutex 或 lock)的起因、使用和实现过程。通过互斥锁, 8 | 可以让线程在临界区执行时,独占临界资源。当我们需要更灵活的互斥访问或同步操作方式,如提供了最多只允许 9 | N 个线程访问临界资源的情况,让某个线程等待另外一个线程执行完毕后再继续执行的同步过程等, 10 | 互斥锁这种方式就有点力不从心了。 11 | 12 | 在本节中,将介绍功能更加强大和灵活的同步互斥机制 -- 信号量(Semaphore),它的设计思路、 13 | 使用和在操作系统中的具体实现。可以看到,信号量的实现需要互斥锁和处理器原子指令的支持, 14 | 它是一种更高级的同步互斥机制。 15 | 16 | 17 | 信号量的起源和基本思路 18 | ----------------------------------------- 19 | 20 | 1963 年前后,当时的数学家(其实是计算机科学家)Edsger Dijkstra 和他的团队在为 Electrologica X8 21 | 计算机开发一个操作系统(称为 THE multiprogramming system,THE 多道程序系统)的过程中,提出了信号量 22 | (Semphore)是一种变量或抽象数据类型,用于控制多个线程对共同资源的访问。 23 | 24 | 信号量是对互斥锁的一种巧妙的扩展。上一节中的互斥锁的初始值一般设置为 1 的整型变量, 25 | 表示临界区还没有被某个线程占用。互斥锁用 0 表示临界区已经被占用了,用 1 表示临界区为空,再通过 26 | ``lock/unlock`` 操作来协调多个线程轮流独占临界区执行。而信号量的初始值可设置为 N 的整数变量, 如果 N 27 | 大于 0, 表示最多可以有 N 个线程进入临界区执行,如果 N 小于等于 0 ,表示不能有线程进入临界区了, 28 | 必须在后续操作中让信号量的值加 1 ,才能唤醒某个等待的线程。 29 | 30 | Dijkstra 对信号量设计了两种操作:P(Proberen(荷兰语),尝试)操作和 V(Verhogen(荷兰语),增加)操作。 31 | P 操作是检查信号量的值是否大于 0,若该值大于 0,则将其值减 1 并继续(表示可以进入临界区了);若该值为 32 | 0,则线程将睡眠。注意,此时 P 操作还未结束。而且由于信号量本身是一种临界资源(可回想一下上一节的锁, 33 | 其实也是一种临界资源),所以在 P 操作中,检查/修改信号量值以及可能发生的睡眠这一系列操作, 34 | 是一个不可分割的原子操作过程。通过原子操作才能保证,一旦 P 操作开始,则在该操作完成或阻塞睡眠之前, 35 | 其他线程均不允许访问该信号量。 36 | 37 | V 操作会对信号量的值加 1 ,然后检查是否有一个或多个线程在该信号量上睡眠等待。如有, 38 | 则选择其中的一个线程唤醒并允许该线程继续完成它的 P 操作;如没有,则直接返回。注意,信号量的值加 1, 39 | 并可能唤醒一个线程的一系列操作同样也是不可分割的原子操作过程。不会有某个进程因执行 V 操作而阻塞。 40 | 41 | 如果信号量是一个任意的整数,通常被称为计数信号量(Counting Semaphore),或一般信号量(General 42 | Semaphore);如果信号量只有0或1的取值,则称为二值信号量(Binary Semaphore)。可以看出, 43 | 互斥锁是信号量的一种特例 --- 二值信号量,信号量很好地解决了最多允许 N 个线程访问临界资源的情况。 44 | 45 | 信号量的一种实现伪代码如下所示: 46 | 47 | .. code-block:: c 48 | :linenos: 49 | 50 | void P(S) { 51 | if (S >= 1) 52 | S = S - 1; 53 | else 54 | ; 55 | } 56 | void V(S) { 57 | if 58 | ; 59 | else 60 | S = S + 1; 61 | } 62 | 63 | 在上述实现中,S 的取值范围为大于等于 0 的整数。S 的初值一般设置为一个大于 0 的正整数, 64 | 表示可以进入临界区的线程数。当 S 取值为 1,表示是二值信号量,也就是互斥锁了。 65 | 使用信号量实现线程互斥访问临界区的伪代码如下: 66 | 67 | .. code-block:: C 68 | :linenos: 69 | 70 | static struct semaphore S = {1}; 71 | 72 | // Thread i 73 | void foo() { 74 | ... 75 | P(S); 76 | execute Cricital Section; 77 | V(S); 78 | ... 79 | } 80 | 81 | 下面是另外一种信号量实现的伪代码: 82 | 83 | .. code-block:: c 84 | :linenos: 85 | 86 | void P(S) { 87 | S = S - 1; 88 | if (S < 0) 89 | ; 90 | } 91 | 92 | void V(S) { 93 | S = S + 1; 94 | if 95 | ; 96 | } 97 | 98 | 在这种实现中,S 的初值一般设置为一个大于 0 的正整数,表示可以进入临界区的线程数。但 S 99 | 的取值范围可以是小于 0 的整数,表示等待进入临界区的睡眠线程数。 100 | 101 | 信号量的另一种用途是用于实现同步(synchronization)。比如,把信号量的初始值设置为 0 , 102 | 当一个线程 A 对此信号量执行一个 P 操作,那么该线程立即会被阻塞睡眠。之后有另外一个线程 B 103 | 对此信号量执行一个 V 操作,就会将线程 A 唤醒。这样线程 B 中执行 V 操作之前的代码序列 B-stmts 104 | 和线程 A 中执行 P 操作之后的代码 A-stmts 序列之间就形成了一种确定的同步执行关系,即线程 B 的 105 | B-stmts 会先执行,然后才是线程 A 的 A-stmts 开始执行。相关伪代码如下所示: 106 | 107 | .. code-block:: C 108 | :linenos: 109 | 110 | static struct semaphore S = {1}; 111 | //Thread A 112 | ... 113 | P(S); 114 | Label_2: 115 | A-stmts after Thread B::Label_1; 116 | ... 117 | 118 | //Thread B 119 | ... 120 | B-stmts before Thread A::Label_2; 121 | Label_1: 122 | V(S); 123 | ... 124 | 125 | 126 | 实现信号量 127 | ------------------------------------------ 128 | 129 | 使用 semaphore 系统调用 130 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 131 | 132 | 我们通过例子来看看如何实际使用信号量。下面是面向应用程序对信号量系统调用的简单使用, 133 | 可以看到对它的使用与上一节介绍的互斥锁系统调用类似。 134 | 135 | 在这个例子中,主线程先创建了信号量初值为 0 的信号量 ``SEM_SYNC`` ,然后再创建两个线程 First 136 | 和 Second 。线程 First 会先睡眠 10ms,而当线程 Second 执行时,会由于执行信号量的 P 137 | 操作而等待睡眠;当线程 First 醒来后,会执行 V 操作,从而能够唤醒线程 Second。这样线程 First 138 | 和线程 Second 就形成了一种稳定的同步关系。 139 | 140 | .. code-block:: C 141 | :linenos: 142 | :emphasize-lines: 5,10,16,23,28,33 143 | 144 | const int SEM_SYNC = 0; //信号量ID 145 | void first() { 146 | sleep(10); 147 | puts("First work and wakeup Second"); 148 | semaphore_up(SEM_SYNC); //信号量V操作 149 | exit(0); 150 | } 151 | void second() { 152 | puts("Second want to continue,but need to wait first"); 153 | semaphore_down(SEM_SYNC); //信号量P操作 154 | puts("Second can work now"); 155 | exit(0); 156 | } 157 | int main() { 158 | // create semaphores 159 | assert_eq(semaphore_create(0), SEM_SYNC); // 信号量初值为0 160 | // create first, second threads 161 | ... 162 | } 163 | 164 | int semaphore_create(int res_count) 165 | { 166 | return syscall(SYS_semaphore_create, res_count); 167 | } 168 | 169 | int semaphore_up(int sid) 170 | { 171 | return syscall(SYS_semaphore_up, sid); 172 | } 173 | 174 | int semaphore_down(int sid) 175 | { 176 | return syscall(SYS_semaphore_down, sid); 177 | } 178 | 179 | 180 | - 第 16 行,创建了一个初值为 0 ,ID 为 ``SEM_SYNC`` 的信号量,对应的是第 23 行 181 | ``SYS_semaphore_create`` 系统调用; 182 | - 第 10 行,线程 Second 执行信号量 P 操作(对应第 33 行 ``SYS_semaphore_down`` 183 | 系统调用),由于信号量初值为 0 ,该线程将阻塞; 184 | - 第 5 行,线程 First 执行信号量 V 操作(对应第 28 行 ``SYS_semaphore_up`` 系统调用), 185 | 会唤醒等待该信号量的线程 Second。 186 | 187 | 实现 semaphore 系统调用 188 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 189 | 190 | 操作系统如何实现信号量系统调用呢?我们还是采用通常的分析做法:数据结构+方法, 191 | 即首先考虑一下与此相关的核心数据结构,然后考虑与数据结构相关的相关函数/方法的实现。 192 | 193 | 在线程的眼里,信号量是一种每个线程能看到的共享资源,且在一个进程中,可以存在多个不同信号量资源, 194 | 所以我们可以把所有的信号量资源放在一起让进程来管理,如下面代码第 4 行所示。这里需要注意的是: 195 | ``struct semaphore semaphore_pool[LOCK_POOL_SIZE]`` 表示的是信号量资源的列表。而 ``struct semaphore`` 196 | 是信号量的内核数据结构,由信号量值和等待队列组成。操作系统需要显式地施加某种控制,来确定当一个线程执行 197 | P 操作和 V 操作时,如何让线程睡眠或唤醒线程。在这里,P 操作是由 ``semaphore_down`` 198 | 方法实现,而 V 操作是由 ``semaphore_up`` 方法实现。 199 | 200 | .. code-block:: c 201 | :linenos: 202 | :emphasize-lines: 4,9-12,31-35,42-47 203 | 204 | struct proc { 205 | int pid; // Process ID 206 | uint next_semaphore_id; 207 | struct semaphore semaphore_pool[LOCK_POOL_SIZE]; 208 | ... 209 | }; 210 | 211 | struct semaphore { 212 | int count; 213 | struct queue wait_queue; 214 | // "alloc" data for wait queue 215 | int _wait_queue_data[WAIT_QUEUE_MAX_LENGTH]; 216 | }; 217 | 218 | struct semaphore *semaphore_create(int count) 219 | { 220 | struct proc *p = curr_proc(); 221 | if (p->next_semaphore_id >= LOCK_POOL_SIZE) { 222 | return NULL; 223 | } 224 | struct semaphore *s = &p->semaphore_pool[p->next_semaphore_id]; 225 | p->next_semaphore_id++; 226 | s->count = count; 227 | init_queue(&s->wait_queue, WAIT_QUEUE_MAX_LENGTH, s->_wait_queue_data); 228 | return s; 229 | } 230 | 231 | void semaphore_up(struct semaphore *s) 232 | { 233 | s->count++; 234 | if (s->count <= 0) { 235 | // count <= 0 after up means wait queue not empty 236 | struct thread *t = id_to_task(pop_queue(&s->wait_queue)); 237 | t->state = RUNNABLE; 238 | add_task(t); 239 | } 240 | } 241 | 242 | void semaphore_down(struct semaphore *s) 243 | { 244 | s->count--; 245 | if (s->count < 0) { 246 | // s->count < 0 means need to wait (state=SLEEPING) 247 | struct thread *t = curr_thread(); 248 | push_queue(&s->wait_queue, task_to_id(t)); 249 | t->state = SLEEPING; 250 | sched(); 251 | } 252 | } 253 | 254 | 首先是核心数据结构: 255 | 256 | - 第 4 行,进程控制块中管理的信号量列表。 257 | - 第 9~12 行,信号量的核心数据成员:信号量值和等待队列。 258 | 259 | 然后是重要的三个成员函数: 260 | 261 | - 第 15 行,创建信号量,信号量初值为参数 ``res_count`` ,信号量池的使用原理同互斥锁。 262 | - 第 28 行,实现 V 操作的 ``up`` 函数,第 31 行,当信号量值小于等于 0 时, 263 | 将从信号量的等待队列中弹出一个线程放入线程就绪队列。 264 | - 第 39 行,实现 P 操作的 ``down`` 函数,第 225 行,当信号量值小于 0 时, 265 | 将把当前线程放入信号量的等待队列,设置当前线程为挂起状态并选择新线程执行。 266 | 267 | 268 | Dijkstra, Edsger W. Cooperating sequential processes (EWD-123) (PDF). E.W. Dijkstra Archive. 269 | Center for American History, University of Texas at Austin. (transcription) (September 1965) 270 | https://www.cs.utexas.edu/users/EWD/transcriptions/EWD01xx/EWD123.html 271 | 272 | Downey, Allen B. (2016) [2005]. "The Little Book of Semaphores" (2nd ed.). Green Tea Press. 273 | 274 | Leppäjärvi, Jouni (May 11, 2008). "A pragmatic, historically oriented survey on the universality 275 | of synchronization primitives" (pdf). University of Oulu, Finland. -------------------------------------------------------------------------------- /source/chapter8/4condition-variable.rst: -------------------------------------------------------------------------------- 1 | 条件变量机制 2 | ========================================= 3 | 4 | 本节导读 5 | ----------------------------------------- 6 | 7 | 到目前为止,我们已经了解了操作系统提供的互斥锁和信号量。但应用程序在使用这两者时需要非常小心, 8 | 如果使用不当,就会产生效率低下、竞态条件、死锁或者其他一些不可预测的情况。为了简化编程、避免错误, 9 | 计算机科学家针对某些情况设计了一种更高层的同步互斥原语。具体而言,在有些情况下, 10 | 线程需要检查某一条件(condition)满足之后,才会继续执行。 11 | 12 | 我们来看一个例子,有两个线程 first 和 second 在运行,线程 first 会把全局变量 A 设置为 13 | 1,而线程 second 在 ``A != 0`` 的条件满足后,才能继续执行,如下面的伪代码所示: 14 | 15 | .. code-block:: C 16 | :linenos: 17 | 18 | static int A = 0; 19 | void first() { 20 | A=1; 21 | ... 22 | } 23 | 24 | void second() { 25 | while (A==0) { 26 | // 忙等或睡眠等待 A==1 27 | }; 28 | //继续执行相关事务 29 | } 30 | 31 | 在上面的例子中,如果线程 second 先执行,会忙等在 while 循环中,在操作系统的调度下,线程 32 | first 会执行并把 A 赋值为 1 后,然后线程 second 再次执行时,就会跳出 while 循环,进行接下来的工作。 33 | 配合互斥锁,可以正确完成上述带条件的同步流程,如下面的伪代码所示: 34 | 35 | .. code-block:: C 36 | :linenos: 37 | 38 | static int A = 0; 39 | void first() { 40 | mutex.lock(); 41 | A=1; 42 | mutex.unlock(); 43 | ... 44 | } 45 | 46 | void second() { 47 | mutex.lock(); 48 | while A == 0 { 49 | mutex.unlock(); 50 | // give other thread chance to lock 51 | mutex.lock(); 52 | } 53 | mutex.unlock(); 54 | //继续执行相关事务 55 | } 56 | 57 | 这种实现能执行,但效率低下,因为线程 second 会忙等检查,浪费处理器时间。我们希望有某种方式让线程 58 | second 休眠,直到等待的条件满足,再继续执行。于是,我们可以写出如下的代码: 59 | 60 | .. code-block:: C 61 | :linenos: 62 | 63 | static int A = 0; 64 | void first() { 65 | mutex.lock(); 66 | A=1; 67 | wakup(second); 68 | mutex.unlock(); 69 | ... 70 | } 71 | 72 | void second() { 73 | mutex.lock(); 74 | while (A==0) { 75 | wait(); 76 | }; 77 | mutex.unlock(); 78 | //继续执行相关事务 79 | } 80 | 81 | 粗略地看,这样就可以实现睡眠等待了。但请同学仔细想想,当线程 second 在睡眠的时候, ``mutex`` 82 | 是否已经上锁了? 确实,线程 second 是带着上锁的 ``mutex`` 进入等待睡眠状态的。 83 | 如果这两个线程的调度顺序是先执行线程 second,再执行线程first,那么线程 second 会先睡眠且拥有 84 | ``mutex`` 的锁;当线程 first 执行时,会由于没有 ``mutex`` 的锁而进入等待锁的睡眠状态。 85 | 结果就是两个线程都睡了,都执行不下去,这就出现了 **死锁** 。 86 | 87 | 这里需要解决的两个关键问题: **如何等待一个条件?** 和 **在条件为真时如何向等待线程发出信号** 。 88 | 我们的计算机科学家给出了 **管程(Monitor)** 和 **条件变量(Condition Variables)** 89 | 这种巧妙的方法。接下来,我们就会深入讲解条件变量的设计与实现。 90 | 91 | 条件变量的基本思路 92 | ------------------------------------------- 93 | 94 | 管程有一个很重要的特性,即任一时刻只能有一个活跃线程调用管程中的过程, 95 | 这一特性使线程在调用执行管程中过程时能保证互斥,这样线程就可以放心地访问共享变量。 96 | 管程是编程语言的组成部分,编译器知道其特殊性,因此可以采用与其他过程调用不同的方法来处理对管程的调用. 97 | 因为是由编译器而非程序员来生成互斥相关的代码,所以出错的可能性要小。 98 | 99 | 管程虽然借助编译器提供了一种实现互斥的简便途径,但这还不够,还需要一种线程间的沟通机制。 100 | 首先是等待机制:由于线程在调用管程中某个过程时,发现某个条件不满足,那就在无法继续运行而被阻塞。 101 | 其次是唤醒机制:另外一个线程可以在调用管程的过程中,把某个条件设置为真,并且还需要有一种机制, 102 | 及时唤醒等待条件为真的阻塞线程。为了避免管程中同时有两个活跃线程, 103 | 我们需要一定的规则来约定线程发出唤醒操作的行为。目前有三种典型的规则方案: 104 | 105 | - Hoare 语义:线程发出唤醒操作后,马上阻塞自己,让新被唤醒的线程运行。注:此时唤醒线程的执行位置还在管程中。 106 | - Hansen 语义:是执行唤醒操作的线程必须立即退出管程,即唤醒操作只可能作为一个管程过程的最后一条语句。 107 | 注:此时唤醒线程的执行位置离开了管程。 108 | - Mesa 语义:唤醒线程在发出行唤醒操作后继续运行,并且只有它退出管程之后,才允许等待的线程开始运行。 109 | 注:此时唤醒线程的执行位置还在管程中。 110 | 111 | 一般开发者会采纳 Brinch Hansen 的建议,因为它在概念上更简单,并且更容易实现。这种沟通机制的具体实现就是 112 | **条件变量** 和对应的操作:wait 和 signal。线程使用条件变量来等待一个条件变成真。 113 | 条件变量其实是一个线程等待队列,当条件不满足时,线程通过执行条件变量的 wait 114 | 操作就可以把自己加入到等待队列中,睡眠等待(waiting)该条件。另外某个线程,当它改变条件为真后, 115 | 就可以通过条件变量的 signal 操作来唤醒一个或者多个等待的线程(通过在该条件上发信号),让它们继续执行。 116 | 117 | 早期提出的管程是基于 Concurrent Pascal 来设计的,其他语言如 C 和 Rust 等,并没有在语言上支持这种机制。 118 | 我们还是可以用手动加入互斥锁的方式来代替编译器,就可以在 C 和 Rust 的基础上实现原始的管程机制了。 119 | 在目前的 C 语言应用开发中,实际上也是这么做的。这样,我们就可以用互斥锁和条件变量, 120 | 来重现上述的同步互斥例子: 121 | 122 | .. code-block:: C 123 | :linenos: 124 | 125 | static int A = 0; 126 | void first() { 127 | mutex.lock(); 128 | A=1; 129 | condvar.wakup(); 130 | mutex.unlock(); 131 | ... 132 | } 133 | 134 | void second() { 135 | mutex.lock(); 136 | while (A==0) { 137 | condvar.wait(mutex); //在睡眠等待之前,需要释放mutex 138 | }; 139 | mutex.unlock(); 140 | //继续执行相关事务 141 | } 142 | 143 | 有了上面的介绍,我们就可以实现条件变量的基本逻辑了。下面是条件变量的 wait 和 signal 操作的伪代码: 144 | 145 | .. code-block:: C 146 | :linenos: 147 | 148 | void wait(mutex) { 149 | mutex.unlock(); 150 | ; 151 | mutex.lock(); 152 | } 153 | 154 | void signal() { 155 | ; 156 | } 157 | 158 | 条件变量的wait操作包含三步,1. 释放锁;2. 把自己挂起;3. 被唤醒后,再获取锁。条件变量的 signal 159 | 操作只包含一步:找到挂在条件变量上睡眠的线程,把它唤醒。 160 | 161 | 注意,条件变量不像信号量那样有一个整型计数值的成员变量,所以条件变量也不能像信号量那样有读写计数值的能力。 162 | 如果一个线程向一个条件变量发送唤醒操作,但是在该条件变量上并没有等待的线程,则唤醒操作实际上什么也没做。 163 | 164 | 实现条件变量 165 | ------------------------------------------- 166 | 167 | 使用 condvar 系统调用 168 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 169 | 170 | 我们通过例子来看看如何实际使用条件变量。下面是面向应用程序对条件变量系统调用的简单使用, 171 | 可以看到对它的使用与上一节介绍的信号量系统调用类似。 在这个例子中,主线程先创建了初值为 1 172 | 的互斥锁和一个条件变量,然后再创建两个线程 First 和 Second。线程 First 会先睡眠 10ms,而当线程 173 | Second 执行时,会由于条件不满足执行条件变量的 wait 操作而等待睡眠;当线程 First 醒来后,通过设置 174 | A 为 1,让线程 second 等待的条件满足,然后会执行条件变量的 signal 操作,从而能够唤醒线程 Second。 175 | 这样线程 First 和线程 Second 就形成了一种稳定的同步与互斥关系。 176 | 177 | .. code-block:: C 178 | :linenos: 179 | :emphasize-lines: 34,44,39 180 | 181 | static int A = 0; //全局变量 182 | 183 | const int CONDVAR_ID = 0; 184 | const int MUTEX_ID = 0; 185 | 186 | void first() { 187 | sleep(10); 188 | puts("First work, Change A --> 1 and wakeup Second"); 189 | mutex_lock(MUTEX_ID); 190 | A = 1; 191 | condvar_signal(CONDVAR_ID); 192 | mutex_unlock(MUTEX_ID); 193 | ... 194 | } 195 | void second() { 196 | puts("Second want to continue,but need to wait A=1"); 197 | mutex_lock(MUTEX_ID); 198 | while (A == 0) { 199 | condvar_wait(CONDVAR_ID, MUTEX_ID); 200 | } 201 | mutex_unlock(MUTEX_ID); 202 | ... 203 | } 204 | int main() { 205 | // create condvar & mutex 206 | assert_eq(condvar_create(), CONDVAR_ID); 207 | assert_eq(mutex_blocking_create(), MUTEX_ID); 208 | // create first, second threads 209 | ... 210 | } 211 | 212 | int condvar_create() 213 | { 214 | return syscall(SYS_condvar_create); 215 | } 216 | 217 | int condvar_signal(int cid) 218 | { 219 | return syscall(SYS_condvar_signal, cid); 220 | } 221 | 222 | int condvar_wait(int cid, int mid) 223 | { 224 | return syscall(SYS_condvar_wait, cid, mid); 225 | } 226 | 227 | - 第 26 行,创建了一个 ID 为 ``CONDVAR_ID`` 的条件量,对应第 34 行 ``SYSCALL_CONDVAR_CREATE`` 系统调用; 228 | - 第 19 行,线程 Second 执行条件变量 ``wait`` 操作(对应第 44 行 ``SYSCALL_CONDVAR_WAIT`` 系统调用), 229 | 该线程将释放 ``mutex`` 锁并阻塞; 230 | - 第 5 行,线程 First 执行条件变量 ``signal`` 操作(对应第 39 行 ``SYSCALL_CONDVAR_SIGNAL`` 系统调用), 231 | 会唤醒等待该条件变量的线程 Second。 232 | 233 | 234 | 实现 condvar 系统调用 235 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 236 | 237 | 操作系统如何实现条件变量系统调用呢?在线程的眼里,条件变量是一种每个线程能看到的共享资源, 238 | 且在一个进程中,可以存在多个不同条件变量资源,所以我们可以把所有的条件变量资源放在一起让进程来管理, 239 | 如下面代码第9行所示。这里需要注意的是: ``condvar_list: Vec>>`` 240 | 表示的是条件变量资源的列表。而 ``Condvar`` 是条件变量的内核数据结构,由等待队列组成。 241 | 操作系统需要显式地施加某种控制,来确定当一个线程执行 ``wait`` 操作和 ``signal`` 操作时, 242 | 如何让线程睡眠或唤醒线程。在这里, ``wait`` 操作是由 ``Condvar`` 的 ``wait`` 方法实现,而 ``signal`` 243 | 操作是由 ``Condvar`` 的 ``signal`` 方法实现。 244 | 245 | .. code-block:: C 246 | :linenos: 247 | :emphasize-lines: 5,13,17,29,38 248 | 249 | // os/proc.h 250 | struct proc { 251 | int pid; // Process ID 252 | uint next_condvar_id; 253 | struct condvar condvar_pool[LOCK_POOL_SIZE]; 254 | ... 255 | }; 256 | 257 | // os/sync.h 258 | struct condvar { 259 | struct queue wait_queue; 260 | // "alloc" data for wait queue 261 | int _wait_queue_data[WAIT_QUEUE_MAX_LENGTH]; 262 | }; 263 | 264 | // os/sync.c 265 | struct condvar *condvar_create() 266 | { 267 | struct proc *p = curr_proc(); 268 | if (p->next_condvar_id >= LOCK_POOL_SIZE) { 269 | return NULL; 270 | } 271 | struct condvar *c = &p->condvar_pool[p->next_condvar_id]; 272 | p->next_condvar_id++; 273 | init_queue(&c->wait_queue, WAIT_QUEUE_MAX_LENGTH, c->_wait_queue_data); 274 | return c; 275 | } 276 | 277 | void cond_signal(struct condvar *cond) 278 | { 279 | struct thread *t = id_to_task(pop_queue(&cond->wait_queue)); 280 | if (t) { 281 | t->state = RUNNABLE; 282 | add_task(t); 283 | } 284 | } 285 | 286 | void cond_wait(struct condvar *cond, struct mutex *m) 287 | { 288 | // conditional variable will unlock the mutex first and lock it again on return 289 | mutex_unlock(m); 290 | struct thread *t = curr_thread(); 291 | // now just wait for cond 292 | push_queue(&cond->wait_queue, task_to_id(t)); 293 | t->state = SLEEPING; 294 | sched(); 295 | mutex_lock(m); 296 | } 297 | 298 | 首先是核心数据结构: 299 | 300 | - 第 5 行,进程控制块中管理的条件变量列表。 301 | - 第 13 行,条件变量的核心数据成员:等待队列。 302 | 303 | 然后是重要的三个成员函数: 304 | 305 | - 第 17 行,创建条件变量,即创建了一个空的等待队列。 306 | - 第 29 行,实现 ``signal`` 操作,将从条件变量的等待队列中弹出一个线程放入线程就绪队列。 307 | - 第 38 行,实现 ``wait`` 操作,释放 ``m`` 互斥锁,将把当前线程放入条件变量的等待队列, 308 | 设置当前线程为挂起状态并选择新线程执行。在恢复执行后,再加上 ``m`` 互斥锁。 309 | 310 | Hansen, Per Brinch (1993). "Monitors and concurrent Pascal: a personal history". HOPL-II: 311 | The second ACM SIGPLAN conference on History of programming languages. History of Programming 312 | Languages. New York, NY, USA: ACM. pp. 1–35. doi:10.1145/155360.155361. ISBN 0-89791-570-4. 313 | -------------------------------------------------------------------------------- /source/chapter8/5exercise.rst: -------------------------------------------------------------------------------- 1 | chapter8 练习 2 | ======================================= 3 | 4 | - 本节难度:助教自评为工作量最高一次,请尽早开始 5 | 6 | 本章任务 7 | ----------------------------------------------------- 8 | 9 | - 本次任务对应 lab5,也是本学期最后一次实验,祝你好运。 10 | - 老规矩,先 `make test BASE=1` 看下啥情况。 11 | - 理解框架的多线程机制,了解几种锁的运行原理。在此基础上,实现本章编程作业死锁检测。 12 | - 如果时间有限,多线程机制的一些细节大可跳过,但至少应知道多线程基本原理和本章在任务调度粒度上的调整 13 | - 与实验息息相关的是互斥锁(mutex)与信号量(semaphore),条件变量(condvar)供阅读 14 | - 框架包含 ``LAB5`` 字样的注释中给出了一个供参考的实现位置和顺序,你可以按顺序完成(下面的标号与注释中的一种): 15 | - 1: 定义并初始化部分 PCB 的部分变量,包括控制死锁检测启动与死锁检测算法用到的变量, 16 | 你可以先定义一部分,后面发现有需要时再做添加; 17 | - 2: 完成系统调用 ``sys_enable_deadlock_detect``,只需要修改变量,不必考虑是否正确实现了死锁。 18 | 完成这一步后你可以顺利跑完 ``ch8_sem2_deadlock``,这个测例开启了死锁检测但并没有死锁; 19 | - 3: 尝试写一个函数实现下面提到的死锁检测算法,注释中给了供参考的函数签名。 20 | 这是一个和OS独立的函数,你可以自行设计数据单独运行它以测试; 21 | - 4-1: 维护 mutex 相关的死锁检测变量,并调用死锁检测算法,完成后你可以顺利跑完测例 ``ch8_mut1_deadlock``; 22 | - 4-2: 维护 semaphore 相关的死锁检测变量,并调用死锁检测算法,完成后你可以顺利跑完测例 ``ch8_sem1_deadlock``; 23 | - 最终,完成实验报告并 push 你的 ch8 分支到远程仓库。push 代码后会自动执行 CI,代码给分以 CI 给分为准。 24 | 25 | 26 | 编程作业 27 | -------------------------------------- 28 | 29 | .. note:: 30 | 31 | 本次实验框架变动较大,且改动较为复杂,为降低同学们的工作量,本次实验不要求合并之前的实验内容, 32 | 可以直接 checkout 到助教的 ch8 框架开始实验,最终只需通过 ch8 系列的测例和前面章节的基础测例即可。 33 | 34 | .. warning:: 35 | 36 | 本次实验实现死锁检测算法本身只需要40行左右代码,但加上系统调用实现、变量声明与初始化、 37 | 以及在锁的创建、锁、释放时维护死锁检测 Available、Allocation、Request 数组, 38 | 总代码量预计在100行左右。助教的参考实现约为90行。 39 | 40 | 41 | 死锁检测 42 | +++++++++++++++++++++++++++++++ 43 | 44 | 目前的 mutex 和 semaphore 相关的系统调用不会分析资源的依赖情况,用户程序可能出现死锁。 45 | 我们希望在系统中加入死锁检测机制,当发现可能发生死锁时拒绝对应的资源获取请求。 46 | 一种检测死锁的算法如下: 47 | 48 | 定义如下三个数据结构: 49 | 50 | - 可利用资源向量 Available :含有 m 个元素的一维数组,每个元素代表可利用的某一类资源的数目, 51 | 其初值是该类资源的全部可用数目,其值随该类资源的分配和回收而动态地改变。 52 | Available[j] = k,表示第 j 类资源的可用数量为 k。 53 | - 分配矩阵 Allocation:n * m 矩阵,表示每类资源已分配给每个线程的资源数。 54 | Allocation[i,j] = g,则表示线程 i 当前己分得第 j 类资源的数量为 g。 55 | - 需求矩阵 Request:n * m 的矩阵,表示每个线程还需要的各类资源数量。 56 | Request[i,j] = d,则表示线程 i 还需要第 j 类资源的数量为 d 。 57 | 58 | 算法运行过程如下: 59 | 60 | 1. 设置两个向量: 工作向量 Work,表示操作系统可提供给线程继续运行所需的各类资源数目,它含有 61 | m 个元素。初始时,Work = Available ;结束向量 Finish,表示系统是否有足够的资源分配给线程, 62 | 使之运行完成。初始时 Finish[0~n-1] = false,表示所有线程都没结束;当有足够资源分配给线程时, 63 | 设置 Finish[i] = true。 64 | 2. 从线程集合中找到一个能满足下述条件的线程 i 65 | 66 | .. code-block:: 67 | :linenos: 68 | 69 | Finish[i] == false; 70 | Request[i,0~n-1] ≤ Work[0~n-1]; 71 | 72 | 若找到,执行步骤 3,否则执行步骤 4。 73 | 74 | 3. 当线程 i 获得资源后,可顺利执行,直至完成,并释放出分配给它的资源,故应执行: 75 | 76 | .. code-block:: 77 | :linenos: 78 | 79 | Work[0~n-1] = Work[0~n-1] + Allocation[i, 0~n-1]; 80 | Finish[i] = true; 81 | 82 | 跳转回步骤2 83 | 84 | 4. 如果 Finish[0~n-1] 都为 true,则表示系统处于安全状态;否则表示系统处于不安全状态,即出现死锁。 85 | 86 | 出于兼容性和灵活性考虑,我们允许进程按需开启或关闭死锁检测功能。为此我们将实现一个新的系统调用: 87 | ``sys_enable_deadlock_detect`` 。 88 | 89 | **enable_deadlock_detect**: 90 | 91 | - syscall ID: 469 92 | - 功能:为当前进程启用或禁用死锁检测功能。 93 | - 接口: ``int enable_deadlock_detect(int is_enable)`` 94 | - 参数: 95 | - is_enable: 为 1 表示启用死锁检测, 0 表示禁用死锁检测。 96 | - 说明: 97 | - 开启死锁检测功能后, ``mutex_lock`` 和 ``semaphore_down`` 如果检测到死锁, 98 | 应拒绝相应操作并返回 -0xDEAD (十六进制值)。 99 | - 简便起见可对 mutex 和 semaphore 分别进行检测,无需考虑二者 (以及 ``waittid`` 等) 100 | 混合使用导致的死锁。 101 | - 返回值:如果出现了错误则返回 -1,否则返回 0。 102 | - 可能的错误 103 | - 参数不合法 104 | 105 | 问答作业 106 | -------------------------------------------- 107 | 108 | 109 | 1. 在我们的多线程实现中,当主线程 (即 0 号线程) 退出时,视为整个进程退出, 110 | 此时需要结束该进程管理的所有线程并回收其资源。 111 | 112 | - 需要回收的资源有哪些? 113 | - 其他线程的 ``struct thread`` 可能在哪些位置被引用,分别是否需要回收,为什么? 114 | 115 | 2. 对比以下两种 ``mutex_unlock`` 中阻塞锁的实现,二者有什么区别?这些区别可能会导致什么问题? 116 | (假设无论哪种实现,对应的 ``mutex_lock`` 均正确处理了 ``m->locked``) 117 | 118 | .. code-block:: C 119 | :linenos: 120 | 121 | void mutex_unlock_v1(struct mutex *m) 122 | { 123 | if (m->blocking) { 124 | m->locked = 0; 125 | struct thread *t = id_to_task(pop_queue(&m->wait_queue)); 126 | if (t != NULL) { 127 | t->state = RUNNABLE; 128 | add_task(t); 129 | } 130 | } else ... 131 | } 132 | 133 | void mutex_unlock_v2(struct mutex *m) 134 | { 135 | if (m->blocking) { 136 | struct thread *t = id_to_task(pop_queue(&m->wait_queue)); 137 | if (t == NULL) { 138 | m->locked = 0; 139 | } else { 140 | t->state = RUNNABLE; 141 | add_task(t); 142 | } 143 | } else ... 144 | } 145 | 146 | 147 | 报告要求 148 | ------------------------------- 149 | 150 | 注意目录要求,报告命名 ``lab5.md`` 或 ``lab5.pdf``,位于 reports 目录下。 后续实验同理。 151 | 152 | - 简单总结你实现的功能(200字以内,不要贴代码)及你完成本次实验所用的时间。 153 | - 完成 ch8 问答题。 154 | - (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。 155 | -------------------------------------------------------------------------------- /source/chapter8/index.rst: -------------------------------------------------------------------------------- 1 | 第八章:并发 2 | ============================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | 0intro 8 | 1thread-kernel 9 | 2lock 10 | 3semaphore 11 | 4condition-variable 12 | 5exercise 13 | -------------------------------------------------------------------------------- /source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'uCore-Tutorial-Guide-2022S' 21 | copyright = '2022, Yifan Wu' 22 | author = 'Yifan Wu' 23 | language = 'zh_CN' 24 | html_search_language = 'zh' 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = '0.1' 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx_comments" 37 | ] 38 | 39 | comments_config = { 40 | "utterances": { 41 | "repo": "LearningOS/uCore-Tutorial-Guide-2022S", 42 | "issue-term": "pathname", 43 | "label": "comments", 44 | "theme": "github-light", 45 | "crossorigin": "anonymous", 46 | } 47 | } 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ['_templates'] 51 | 52 | # List of patterns, relative to source directory, that match files and 53 | # directories to ignore when looking for source files. 54 | # This pattern also affects html_static_path and html_extra_path. 55 | exclude_patterns = [] 56 | 57 | 58 | # -- Options for HTML output ------------------------------------------------- 59 | 60 | # The theme to use for HTML and HTML Help pages. See the documentation for 61 | # a list of builtin themes. 62 | # 63 | html_theme = 'furo' 64 | 65 | # Add any paths that contain custom static files (such as style sheets) here, 66 | # relative to this directory. They are copied after the builtin static files, 67 | # so a file named "default.css" will overwrite the builtin "default.css". 68 | html_static_path = ['_static'] 69 | 70 | html_css_files = [ 71 | 'my_style.css', 72 | #'dracula.css', 73 | ] 74 | 75 | from pygments.lexer import RegexLexer 76 | from pygments import token 77 | from sphinx.highlighting import lexers 78 | 79 | class RVLexer(RegexLexer): 80 | name = 'riscv' 81 | tokens = { 82 | 'root': [ 83 | # Comment 84 | (r'#.*\n', token.Comment), 85 | # General Registers 86 | (r'\b(?:x[1-2]?[0-9]|x30|x31|zero|ra|sp|gp|tp|fp|t[0-6]|s[0-9]|s1[0-1]|a[0-7]|pc)\b', token.Name.Attribute), 87 | # CSRs 88 | (r'\bs(?:status|tvec|ip|ie|counteren|scratch|epc|cause|tval|atp|)\b', token.Name.Constant), 89 | (r'\bm(?:isa|vendorid|archid|hardid|status|tvec|ideleg|ip|ie|counteren|scratch|epc|cause|tval)\b', token.Name.Constant), 90 | # Instructions 91 | (r'\b(?:(addi?w?)|(slti?u?)|(?:and|or|xor)i?|(?:sll|srl|sra)i?w?|lui|auipc|subw?|jal|jalr|beq|bne|bltu?|bgeu?|s[bhwd]|(l[bhw]u?)|ld)\b', token.Name.Decorator), 92 | (r'\b(?:csrr?[rws]i?)\b', token.Name.Decorator), 93 | (r'\b(?:ecall|ebreak|[msu]ret|wfi|sfence.vma)\b', token.Name.Decorator), 94 | (r'\b(?:nop|li|la|mv|not|neg|negw|sext.w|seqz|snez|sltz|sgtz|f(?:mv|abs|neg).(?:s|d)|b(?:eq|ne|le|ge|lt)z|bgt|ble|bgtu|bleu|j|jr|ret|call)\b', token.Name.Decorator), 95 | (r'(?:%hi|%lo|%pcrel_hi|%pcrel_lo|%tprel_(?:hi|lo|add))', token.Name.Decorator), 96 | # Directives 97 | (r'(?:.2byte|.4byte|.8byte|.quad|.half|.word|.dword|.byte|.dtpreldword|.dtprelword|.sleb128|.uleb128|.asciz|.string|.incbin|.zero)', token.Name.Function), 98 | (r'(?:.align|.balign|.p2align)', token.Name.Function), 99 | (r'(?:.globl|.local|.equ)', token.Name.Function), 100 | (r'(?:.text|.data|.rodata|.bss|.comm|.common|.section)', token.Name.Function), 101 | (r'(?:.option|.macro|.endm|.file|.ident|.size|.type)', token.Name.Function), 102 | (r'(?:.set|.rept|.endr|.macro|.endm|.altmacro)', token.Name.Function), 103 | # Number 104 | (r'\b(?:(?:0x|)[\da-f]+|(?:0o|)[0-7]+|\d+)\b', token.Number), 105 | # Labels 106 | (r'\S+:', token.Name.Builtin), 107 | # Whitespace 108 | (r'\s', token.Whitespace), 109 | # Other operators 110 | (r'[,\+\*\-\(\)\\%]', token.Text), 111 | # Hacks 112 | (r'(?:SAVE_GP|trap_handler|__switch|LOAD_GP|SAVE_SN|LOAD_SN|__alltraps|__restore)', token.Name.Builtin), 113 | (r'(?:.trampoline)', token.Name.Function), 114 | (r'(?:n)', token.Name.Entity), 115 | (r'(?:x)', token.Text), 116 | ], 117 | } 118 | 119 | lexers['riscv'] = RVLexer() 120 | -------------------------------------------------------------------------------- /source/index.rst: -------------------------------------------------------------------------------- 1 | .. uCore-Tutorial-Guide-2022S documentation master file, created by 2 | sphinx-quickstart on Thu Oct 29 22:25:54 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | uCore-Tutorial-Guide 2022 Spring 7 | ================================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Part1 - Just do it! 12 | :hidden: 13 | 14 | chapter0/index 15 | chapter1/index 16 | chapter2/index 17 | chapter3/index 18 | chapter4/index 19 | chapter5/index 20 | chapter6/index 21 | chapter7/index 22 | chapter8/index 23 | 24 | .. toctree:: 25 | :maxdepth: 2 26 | :caption: 开发注记 27 | :hidden: 28 | 29 | setup-sphinx 30 | rest-example 31 | log 32 | 33 | 欢迎来到 uCore-Tutorial-Guide 2022 Spring! 34 | 35 | 指导书简介 36 | ---------------------------- 37 | 38 | 该指导书为 `THU` `OS` 课程实验 `C` 版实验指导书,旨在帮助同学们快速熟悉框架并完成书面和编程任务, `配套代码 `_ 。 39 | 40 | 此外,还推荐有余力同学们参考 `rCore-Tutorial 指导书 `_。该书为一本从零开始写一个 OS 的教材,虽然是 rust 语言编写的,但对于 OS 的宏观特征和部分细节有更详细的描述。本指导书大量引用了该书的部分章节。 41 | 42 | 导读 43 | --------------------- 44 | 45 | 此实验的设计,大致还原了历史上 OS 的演变历程,感兴趣的同学可以参考 `WHAT-IS-OS `_。 46 | 47 | 在正式进行实验之前,请先按照第零章章末的 :doc:`/chapter0/1setup-devel-env` 中的说明完成环境配置,确保能够正常运行 ch1 分支的代码。 48 | 49 | 此外需要注意指导书章节与实验提交要求的不一致,该指导书有 8 个章节,其中: ``ch1`` ``ch2`` ``ch3`` 对应课程要求 ``lab1``; ``ch4`` 对应 ``lab2``; ``ch5`` ``ch6`` 对应 ``lab3``; ``ch7`` 对应 ``lab4``; ``ch8`` 对应 ``lab5``。 50 | 51 | 52 | 项目协作 53 | ---------------------- 54 | 55 | - :doc:`/setup-sphinx` 介绍了如何基于 Sphinx 框架配置文档开发环境,之后可以本地构建并渲染 html 或其他格式的文档; 56 | - :doc:`/rest-example` 给出了目前编写文档才用的 ReStructuredText 标记语言的一些基础语法及用例; 57 | - `该文档仓库文档仓库 `_ 58 | - 时间仓促,本项目还有很多不完善之处,欢迎大家积极在每一个章节的评论区留言,或者提交 Issues 或 Pull Requests,让我们 59 | 一起努力让这本书变得更好! 60 | 61 | 62 | 项目进度 63 | ----------------------- 64 | 65 | - 2021-09-09: 基本完成初稿。 -------------------------------------------------------------------------------- /source/pygments-coloring.txt: -------------------------------------------------------------------------------- 1 | Pygments 默认配色: 2 | Keyword.Constant 深绿加粗 3 | Keyword.Declaration 深绿加粗 4 | Keyword.Namespace 深绿加粗 5 | Keyword.Pseudo 浅绿 6 | Keyword.Reserved 深绿加粗 7 | Keyword.Type 樱桃红 8 | Name.Attribute 棕黄 9 | Name.Builtin 浅绿 10 | Name.Builtin.Pseudo 浅绿 11 | Name.Class 深蓝加粗 12 | Name.Constant 棕红 13 | Name.Decorator 浅紫 14 | Name.Entity 灰色 15 | Name.Exception 深红 16 | Name.Function 深蓝 17 | Name.Function.Magic 深蓝 18 | Name.Label 棕黄 19 | Name.Namespace 深蓝加粗 20 | Name.Other 默认黑色 21 | Name.Tag 深绿加粗 22 | Name.Variable 蓝黑 23 | 24 | 25 | 通用寄存器 -> 棕黄 Name.Attribute 26 | CSR -> 棕红 Name.Constant 27 | 指令 -> 浅紫 Name.Decorator 28 | 伪指令 -> 樱桃红 Keyword.Type 29 | Directives -> 深蓝 Name.Function 30 | 标签/剩余字面量 -> 浅绿 Name.Builtin 31 | 数字 -> Number 32 | 33 | -------------------------------------------------------------------------------- /source/resources/test.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LearningOS/uCore-Tutorial-Guide-2022S/bddb04e22526da72f39234ca16d451ac8662580e/source/resources/test.gif -------------------------------------------------------------------------------- /source/rest-example.rst: -------------------------------------------------------------------------------- 1 | reStructuredText 基本语法 2 | ===================================================== 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 4 7 | 8 | .. note:: 9 | 下面是一个注记。 10 | 11 | `这里 `_ 给出了在 Sphinx 中 12 | 外部链接的引入方法。注意,链接的名字和用一对尖括号包裹起来的链接地址之间必须有一个空格。链接最后的下划线和片段的后续内容之间也需要 13 | 有一个空格。 14 | 15 | 接下来是一个文档内部引用的例子。比如,戳 :doc:`chapter0/5setup-devel-env` 可以进入快速上手环节。 16 | 17 | .. warning:: 18 | 19 | 下面是一个警告。 20 | 21 | .. code-block:: rust 22 | :linenos: 23 | :caption: 一段示例 Rust 代码 24 | 25 | // 我们甚至可以插入一段 Rust 代码! 26 | fn add(a: i32, b: i32) -> i32 { a + b } 27 | 28 | 下面继续我们的警告。 29 | 30 | .. attention:: Here is an attention. 31 | 32 | .. caution:: please be cautious! 33 | 34 | .. error:: 35 | 36 | 下面是一个错误。 37 | 38 | .. danger:: it is dangerous! 39 | 40 | 41 | .. tip:: here is a tip 42 | 43 | .. important:: this is important! 44 | 45 | .. hint:: this is a hint. 46 | 47 | 48 | 49 | 这里是一行数学公式 :math:`\sin(\alpha+\beta)=\sin\alpha\cos\beta+\cos\alpha\sin\beta`。 50 | 51 | 基本的文本样式:这是 *斜体* ,这是 **加粗** ,接下来的则是行间公式 ``a0`` 。它们的前后都需要有一个空格隔开其他内容,这个让人挺不爽的... 52 | 53 | `这是 `_ 一个全面展示 54 | 章节分布的例子,来自于 ReadTheDocs 的官方文档。事实上,现在我们也采用 ReadTheDocs 主题了,它非常美观大方。 55 | 56 | 下面是一个测试 gif。 57 | 58 | .. image:: resources/test.gif 59 | 60 | 接下来是一个表格的例子。 61 | 62 | .. list-table:: RISC-V 函数调用跳转指令 63 | :widths: 20 30 64 | :header-rows: 1 65 | :align: center 66 | 67 | * - 指令 68 | - 指令功能 69 | * - :math:`\text{jal}\ \text{rd},\ \text{imm}[20:1]` 70 | - :math:`\text{rd}\leftarrow\text{pc}+4` 71 | 72 | :math:`\text{pc}\leftarrow\text{pc}+\text{imm}` 73 | * - :math:`\text{jalr}\ \text{rd},\ (\text{imm}[11:0])\text{rs}` 74 | - :math:`\text{rd}\leftarrow\text{pc}+4` 75 | 76 | :math:`\text{pc}\leftarrow\text{rs}+\text{imm}` -------------------------------------------------------------------------------- /source/setup-sphinx.rst: -------------------------------------------------------------------------------- 1 | 修改和构建本项目 2 | ==================================== 3 | 4 | .. toctree:: 5 | :hidden: 6 | :maxdepth: 4 7 | 8 | 1. 参考 `这里 `_ 安装 Sphinx。 9 | 2. ``pip install sphinx_rtd_theme`` 安装 Read The Docs 主题。 10 | 3. ``pip install jieba`` 安装中文分词。 11 | 4. ``pip install sphinx-comments`` 安装 Sphinx 讨论区插件。 12 | 5. :doc:`/rest-example` 是 ReST 的一些基本语法,也可以参考已完成的文档。 13 | 6. 修改之后,在项目根目录下 ``make clean && make html`` 即可在 ``build/html/index.html`` 查看本地构建的主页。请注意在修改章节目录结构之后需要 ``make clean`` 一下,不然可能无法正常更新。 14 | 7. 确认修改无误之后,将 ``main`` 主分支上的修改 merge 到 ``deploy`` 分支,在项目根目录下 ``make deploy`` 即可将更新后的文档部署到用于部署的 ``deploy`` 分支上。 15 | 如果与其他人的提交冲突的话,请删除掉 ``docs`` 目录再进行 merge。 16 | --------------------------------------------------------------------------------