操作系统 - 更方便的运行时环境
我们在PA2中已经实现了一个冯诺依曼计算机系统, 并且已经在AM上把打字游戏运行起来了. 有了IOE, 几乎能把各种小游戏移植到AM上来运行了. 但说起运行仙剑奇侠传, 我们目前暂时还无能为力. 这主要是因为, 仙剑奇侠传算得上是一个较为复杂的游戏了, 它会使用文件来管理游戏相关的数据. 一提到文件, 就已经超出了AM的能力范围了, 因为AM只是计算机的一种抽象模型, 是用来描述计算机如何构成的, 作为运行时环境对程序的支撑能力也很有限, 显然文件的概念并不属于AM.
事实上, 我们每天都使用的文件, 其实是操作系统提供的一种服务. 仔细想想, 我们使用的绝大部分程序, 都是在操作系统上运行的, 这是因为操作系统的层次比ISA和AM都要高, 自然能提供更丰富的抽象和更方便的运行时环境. 如果要让开发者在AM上进行开发, 估计各种游戏都早已陷入无尽跳票的死循环中了.
因此, 为了运行规模更大的程序, 我们需要操作系统的支持. 噢, 可别被操作系统这个庞然大物吓到了, 我们只需要一个支持文件操作的操作系统, 就可以支撑仙剑奇侠传的运行了. 感觉还是比较复杂啊, 我们还是先做一件最简单的事情: 先实现一个足够简单的操作系统, 来支撑dummy程序的运行.
RTFSC(4)
框架代码中已经为大家准备好了Nanos-lite的代码. Nanos-lite是南京大学操作系统Nanos的裁剪版, 是一个为PA量身订造的操作系统. 换句话说, 我们现在就要在NEMU上运行一个操作系统了(尽管这是一个比较简陋的操作系统), 同时也将带领你根据课堂上的知识剖析一个简单操作系统的组成. 这不仅是作为对这些抽象知识的很好的复(预)习, 同时也是为以后的操作系统实验打下坚实的基础, 而对PA来说最重要的是, 体会操作系统对运行程序的意义所在.
Nanos-lite已经包含了后续PA用到的所有模块,
由于NEMU的功能是逐渐添加的, Nanos-lite也要配合这个过程,
你会通过nanos-lite/src/main.c
中的一些与实验进度相关的宏来控制Nanos-lite的功能.
随着实验进度的推进, 我们会逐渐讲解所有的模块, Nanos-lite做的工作也会越来越多.
因此在阅读Nanos-lite的代码时, 你只需要关心和当前进度相关的模块就可以了, 不要纠缠于和当前进度无关的代码.
nanos-lite
├── include
│ ├── common.h
│ ├── debug.h
│ ├── fs.h
│ ├── memory.h
│ └── proc.h
├── Makefile
└── src
├── device.c # 设备抽象
├── fs.c # 文件系统
├── initrd.S # ramdisk设备
├── irq.c # 中断異常处理
├── loader.c # 加载器
├── main.c
├── mm.c # 存储管理
├── proc.c # 进程调度
├── ramdisk.c # ramdisk驱动程序
└── syscall.c # 系统调用处理
需要提醒的是, Nanos-lite是运行在AM之上的, AM的API在Nanos-lite中都是可用的. 因此我们会有以下说法:
Nanos-lite是NEMU的客户程序, 它运行在AM之上.
仙剑是Nanos-lite的用户程序, 它运行在Nanos-lite之上.
另外, 虽然不会引起明显的误解, 但在引入Nanos-lite之后, 我们还是会在某些地方使用"用户进程"的概念, 而不是"用户程序". 如果你现在不能理解什么是进程, 你只需要把进程作为"正在运行的程序"来理解就可以了. 还感觉不出这两者的区别? 举一个简单的例子吧, 如果你打开了记事本3次, 计算机上就会有3个记事本进程在运行, 但磁盘中的记事本程序只有一个. 进程是操作系统中一个重要的概念, 有关进程的详细知识会在操作系统课上进行介绍.
一开始, 在nanos-lite/src/main.c
中所有与实验进度相关的宏都没有定义,
此时Nanos-lite的功能十分简单.
我们来简单梳理一下Nanos-lite目前的行为:
- 通过
Log()
输出hello信息和编译时间. 需要说明的是, Nanos-lite中定义的Log()
宏并不是NEMU中定义的Log()
宏. Nanos-lite和NEMU是两个独立的项目, 它们的代码不会相互影响, 你在阅读代码的时候需要注意这一点. 在Nanos-lite中,Log()
宏通过klib
中的printk()
输出, 最终会调用TRM的_putc()
. - 初始化ramdisk. 在一个完整的模拟器中, 程序应该存放在磁盘中. 但目前我们并没有实现磁盘的模拟, 因此先把Nanos-lite中的一段内存作为磁盘来使用. 这样的磁盘有一个专门的名字, 叫ramdisk.
- 调用
init_device()
对设备进行一些初始化操作. 目前init_device()
会直接调用_ioe_init()
. - 调用
loader()
函数加载用户程序, 函数会返回用户程序的入口地址. 其中loader()
函数并未实现, 我们会在下文进行说明. - 跳转到用户程序的入口执行.
加载操作系统的第一个用户程序
loader是一个用于加载程序的模块.
我们知道程序中包括代码和数据, 它们都是存储在可执行文件中.
加载的过程就是把可执行文件中的代码和数据放置在正确的内存位置,
然后跳转到程序入口, 程序就开始执行了.
更具体的, 为了实现loader()
函数, 我们需要解决以下问题:
- 可执行文件在哪里?
- 代码和数据在可执行文件的哪个位置?
- 代码和数据有多少?
- "正确的内存位置"在哪里?
为了回答第一个问题, 我们还要先说明一下用户程序是从哪里来的. 由于用户程序运行在操作系统之上, 不能与AM所提供的运行时环境相适配了, 因此我们不能把编译到AM上的程序放到操作系统上运行. 为此, 我们准备了一个新的子项目Navy-apps, 专门用于编译出操作系统的用户程序.
navy-apps
├── apps # 用户程序
│ ├── init
│ ├── litenes
│ ├── lua
│ ├── nterm
│ ├── nwm
│ └── pal # 仙剑奇侠传
├── fsimg # 根文件系统
├── libs # 库
│ ├── libc # Newlib C库
│ ├── libfont
│ ├── libndl
│ └── libos # 系统调用的用户层封装
├── Makefile
├── Makefile.app
├── Makefile.check
├── Makefile.compile
├── Makefile.lib
├── README.md
└── tests # 一些测试
其中, navy-apps/libs/libc
中是一个名为Newlib的项目,
它是一个专门为嵌入式系统提供的C库, 库中的函数对运行时环境的要求极低.
这对Nanos-lite来说是非常友好的, 我们不需要为了配合C库而在Nanos-lite中实现额外的功能.
用户程序的入口位于navy-apps/libs/libc/start.c
中的_start()
函数,
它会调用用户程序的main()
函数,
从main()
函数返回后会调用exit()
结束运行.
我们要在Nanos-lite上运行的第一个用户程序是navy-apps/tests/dummy/dummy.c
.
首先我们让Navy-apps项目上的程序默认编译到x86
中:
--- navy-apps/Makefile.check
+++ navy-apps/Makefile.check
@@ -1,1 +1,1 @@
-ISA ?= native
+ISA ?= x86
然后在navy-apps/tests/dummy
下执行
make
就会在navy-apps/tests/dummy/build/
目录下生成dummy的可执行文件.
编译Newlib时会出现较多warning, 我们可以忽略它们.
为了避免和Nanos-lite的内容产生冲突,
我们约定目前用户程序需要被链接到内存位置0x4000000
处,
Navy-apps已经设置好了相应的选项(见navy-apps/Makefile.compile
中的LDFLAGS
变量).
在nanos-lite/
目录下执行
make update
nanos-lite/Makefile
中会将其生成ramdisk镜像文件ramdisk.img
,
并包含进Nanos-lite成为其中的一部分(在nanos-lite/src/initrd.S
中实现).
现在的ramdisk十分简单, 它只有一个文件, 就是我们将要加载的用户程序,
这其实已经回答了上述第一个问题: 可执行文件位于ramdisk偏移为0处, 访问它就可以得到用户程序的第一个字节.
为了回答剩下的问题, 我们首先需要了解可执行文件是如何组织的.
你应该已经在课堂上学习过ELF文件格式了,
它除了包含程序本身的代码和静态数据之外,
还包括一些用来描述它们的组织信息.
事实上, 我们的loader目前并没有必要去解析并加载ELF文件.
为了简化, nanos-lite/Makefile
中已经把用户程序运行所需要的代码和静态数据通过objcopy
工具从ELF文件中抽取出来了,
整个ramdisk本身就已经存放了loader所需要加载的内容.
最后, "正确的内存位置", 也就是我们上文提到的约定好的0x4000000
了.
所以, 目前的loader只需要做一件事情:
将ramdisk中从0开始的所有内容放置在0x4000000
, 并把这个地址作为程序的入口返回即可.
我们把这个简化了的loader称为raw program loader.
我们通过内存布局来理解loader目前需要做的事情:
ramdisk_start ramdisk_end
| |
+--+ +--+
0 0x100000 v v 0x4000000
+------+------------------------------+-----------+-------+-------
| | +---------+ | | |
| | Nanos-lite | ramdisk | | | dummy |
| | +---------+ | | |
+------+------------------------------+-----------+-------+-------
| ^
+-------------------------+
loader
框架代码提供了一些ramdisk相关的函数(在nanos-lite/src/ramdisk.c
中定义),
你可以使用它们来实现loader的功能:
// 从ramdisk中`offset`偏移处的`len`字节读入到`buf`中
void ramdisk_read(void *buf, off_t offset, size_t len);
// 把`buf`中的`len`字节写入到ramdisk中`offset`偏移处
void ramdisk_write(const void *buf, off_t offset, size_t len);
// 返回ramdisk的大小, 单位为字节
size_t get_ramdisk_size();
真实操作系统中的loader远比我们目前在Nanos-lite中实现的loader要复杂. 事实上, Nanos-lite的loader设计其实也向我们展现出了程序的最为原始的状态: 一个凝结着人类智慧设计的精妙算法, 承载着人类劳动收集的宝贵数据的...比特串! 加载程序其实就是把这一无比珍贵的比特串放置在正确的位置, 但这看似平凡无比的比特串当中又蕴含着"存储程序"的划时代思想: 当操作系统将控制权交给它的时候, 计算机以把它解释成指令并逐条执行, 却让这一比特串真正发挥出它足以改变世界的潜能.
你需要在Nanos-lite中实现loader的功能, 来把用户程序加载到正确的内存位置, 然后执行用户程序.
需要注意的是, 每当ramdisk中的内容需要更新时, 你都需要在nanos-lite/
目录下手动执行
make update
来更新Nanos-lite中的ramdisk内容, 然后再通过
make run
来在NEMU上运行带有最新版ramdisk的Nanos-lite.
实现正确后, 你会看到dummy程序执行了一条未实现的int
指令,
这说明loader已经成功加载dummy, 并且成功地跳转到dummy中执行了.
未实现的int
指令我们会接下来的内容中进行说明.