引言¶
本章导读¶
本章展现了操作系统一系列功能:
通过批处理支持多个程序的自动加载和运行
操作系统利用硬件特权级机制,实现对操作系统自身的保护
上一章,我们在 RV64 裸机平台上成功运行起来了 Hello, world!
并成功实现了染色的过程 。看起来这个过程非常顺利,只需要一条命令就能全部完成。但实际上,在那个计算机刚刚诞生的年代,很多事情并不像我们想象的那么简单。当时,程序被记录在打孔的卡片上,使用汇编语言甚至机器语言来编写。而稀缺且昂贵的计算机由专业的管理员负责操作,就和我们在上一章所做的事情一样,他们手动将卡片输入计算机,等待程序运行结束或者终止程序的运行。最后,他们从计算机的输出端——也就是打印机中取出程序的输出并交给正在休息室等待的程序提交者。
实际上,这样做是一种对于珍贵的计算资源的浪费。因为当时的计算机和今天的个人计算机不同,它的体积极其庞大,能够占满一整个空调房间,像巨大的史前生物。管理员在房间的各个地方跑来跑去、或是等待打印机的输出的这些时间段,计算机都并没有在工作。于是,人们希望计算机能够不间断的工作且专注于计算任务本身。
批处理系统 (Batch System) 应运而生。它的核心思想是:将多个程序打包到一起输入计算机。而当一个程序运行结束后,计算机会 自动 加载下一个程序到内存并开始执行。这便是最早的真正意义上的操作系统。
程序总是难免出现错误。但人们希望一个程序的错误不要影响到操作系统本身,它只需要终止出错的程序,转而运行执行序列中的下一个程序即可。如果后面的程序都无法运行就太糟糕了。这种 保护 操作系统不受有意或无意出错的程序破坏的机制被称为 特权级 (Privilege) 机制,它实现了用户态和内核态的隔离,需要软件和硬件的共同努力。
本章我们的主要目的也是设计一个批处理的操作系统。毕竟将待执行的程序嵌入main.c之中是十分粗暴的,也不符合我们对操作系统的认知。这同时也意味着我们将开始使用独立的测例文件,并把它们打包到os之中。
实践体验¶
本章我们引入了用户程序,为了解耦内核与用户程序,我们分离了两个仓库,分别是存放内核程序的 uCore-Tutorial-Code-20xxx
(下称代码仓库,最后几位 x 表示学期)与存放用户程序的 uCore-Tutorial-Test-20xxx
(下称测例仓库)。 因此首先你需要进入代码仓库文件夹(如果已经执行过该步骤则不需要再重复执行)并 clone 用户程序仓库:
$ cd uCore-Tutorial-Code-2023A
$ git clone https://github.com/LearningOS/uCore-Tutorial-Test-2023A.git user
上面的指令会将测例仓库克隆到代码仓库下并命名为 user
,注意 /user
在代码仓库的 .gitignore
中,因此不会出现 .git
文件夹嵌套的问题,并且你 checkout
代码仓库时也不会影响测例仓库的内容。
注解
如果测例仓库有所更新或者你切换了代码仓库的分支,你可能需要清理掉测例仓库原版的编译结果,此时需要执行
$ make -C user clean
它的作用基本等价于如下写法,但是更简便
$ cd user
$ make clean
$ cd ..
我们可以通过 make user
生成用户程序,最终将 .bin
文件放在 user/target/bin
目录下。
$ git checkout ch2
$ make user BASE=1 CHAPTER=2
$ make run
也可以直接运行打包好的测试程序。make test 会完成 make user 和 make run 两个步骤(自动设置 CHAPTER),我们可以通过 BASE 控制是否生成留做练习的测例。
$ make test BASE=1
如果顺利的话,我们可以看到批处理系统自动加载并运行所有的程序并且正确在程序出错的情况下保护了自身:
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Platform: QEMU (Version 0.1.0)
[rustsbi] misa: RV64ACDFIMSU
[rustsbi] mideleg: 0x222
[rustsbi] medeleg: 0xb1ab
[rustsbi-dtb] Hart count: cluster0 with 1 cores
[rustsbi] Kernel entry: 0x80200000
hello wrold!
Hello world from user mode program!
Test hello_world OK!
3^10000=5079
3^20000=8202
3^30000=8824
3^40000=5750
3^50000=3824
3^60000=8516
3^70000=2510
3^80000=9379
3^90000=2621
3^100000=2749
Test power OK!
string from data section
strinstring from stack section
strin
Test write1 OK!
ALL DONE
可以看到 4 个基础测试程序都可以正常运行。
本章代码导读¶
相比于上一章的操作系统,本章操作系统有两个最大的不同之处,一个是支持应用程序在用户态运行,且能完成应用程序发出的系统调用;另一个是能够一个接一个地自动运行不同的应用程序。所以,我们需要对操作系统和应用程序进行修改,也需要对应用程序的编译生成过程进行修改。
首先改进应用程序,让它能够在用户态执行,并能发出系统调用。这其实就是本章中 实现应用程序以及user文件夹 小节介绍内容。具体而言,编写多个应用小程序,修改编译应用所需的 linker.ld
文件来 调整程序的内存布局 ,让操作系统能够把应用加载到指定内存地址后顺利启动并运行应用程序。
应用程序运行中,操作系统要支持应用程序的输出功能,并还能支持应用程序退出。这需要完成 sys_write
和 sys_exit
系统调用访问请求的实现。 具体实现涉及到内联汇编的编写,以及应用与操作系统内核之间系统调用的参数传递的约定。为了让应用在还没实现操作系统之前就能进行运行测试,我们采用了Linux on RISC-V64 的系统调用参数约定。具体实现可参看 特权级机制 小节中的内容。 这样写完应用小例子后,就可以通过 qemu-riscv64
模拟器进行测试了。
写完应用程序后,还需实现支持多个应用程序轮流启动运行的操作系统。这里首先能把本来相对松散的应用程序执行代码和操作系统执行代码连接在一起,便于 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格式。
操作系统本身需要完成对Binary应用的位置查找,找到后(通过 os/link_app.S
中的变量和标号信息完成),会把Binary应用拷贝到 os/kernel_app.ld
指定的物理内存位置(OS的加载应用功能)。
更加详细的内容,主要在 实现批处理操作系统 小节中讲解。
为了让Binary应用能够启动和运行,操作系统还需给Binary应用分配好执行环境所需一系列的资源。这主要包括设置好用户栈和内核栈(在应用在用户态和内核在内核态需要有各自的栈),实现Trap 上下文的保存与恢复(让应用能够在发出系统调用到内核态后,还能回到用户态继续执行),完成Trap 分发与处理等工作。由于涉及用户态与内核态之间的特权级切换细节的汇编代码,与硬件细节联系紧密,所以 这部分内容 是本章中理解比较困难的地方。如果要了解清楚,需要对涉及到的CSR寄存器的功能有清楚的认识。这就需要看看 RISC-V手册 的第十章或更加详细的RISC-V的特权级规范文档了。有了上面的实现后,就剩下最后一步,实现 执行应用程序 的操作系统功能,其主要实现在 run_next_app
函数中 。