甩锅声明
从PA4开始, 讲义中就不再提供滴水不漏的代码指导了, 部分关键的代码细节需要你自己去思考和尝试(我们故意省略的). 我们会在讲义中将技术的原理阐述清楚, 你需要首先理解这些原理, 然后根据理解来阅读并编写相应的代码.
一句话总结, 与其抱怨讲义写得不清楚, 还不如自己多多思考. 现在都到PA4了, 为了让成绩符合正态分布, 拿高分总需要多付出点努力吧. 如果你之前都是想办法投机取巧而不去深入理解系统如何工作, 现在应该已经没戏了, 老老实实吃亏吧.
多道程序
通过Nanos-lite的支撑, 我们已经在NEMU中成功运行了一个批处理系统, 并把仙剑奇侠传跑起来了! 这说明我们亲自构建的NEMU这个看似简单的机器, 同样能支撑真实程序的运行, 丝毫不逊色于真实的机器! 不过, 这个批处理系统目前还是只能同时运行一个程序, 只有当一个程序结束执行之后, 才会开始执行下一个程序.
这也正是批处理系统的一个缺陷: 如果当前程序正在等待输入输出, 那么整个系统都会因此而停顿. 和CPU的性能相比, 输入输出是非常缓慢的: 以磁盘为例, 磁盘进行一次读写需要花费大约5毫秒的时间, 但对于一个2GHz的CPU来说, 它需要花费10,000,000个周期来等待磁盘操作的完成. 但事实上, 与其让系统陷入无意义的等待, 还不如用这些时间来进行一些有意义的工作. 一个简单的想法就是, 在系统一开始的时候加载多个程序, 然后运行第一个; 当第一个程序需要等待输入输出的时候, 就切换到第二个程序来运行; 当第二个程序也需要等待的时候, 就继续切换到下一个程序来运行, 如此类推.
这就是多道程序(multiprogramming)系统的基本思想. 多道程序的想法听上去很简单, 但它也是一种多任务系统, 这是因为它已经包含了多任务系统的基本要素. 换句话说, 要把批处理的Nanos-lite改造成一个多道程序操作系统, 我们只需要实现以下两点就可以了:
- 在内存中可以同时存在多个进程
- 在满足某些条件的情况下, 可以让执行流在这些进程之间切换
术语变更
既然是多任务系统, 系统中就运行的程序就不止一个了. 现在我们就可以直接使用"进程"的概念了.
要实现第一点并不难, 我们只要让loader把不同的进程加载在不同的内存位置就可以了, 加载进程的过程本质上就是一些内存拷贝的操作, 因此并没有什么困难的地方.
其实我在骗你!
对我们目前实现的计算机系统来说, "把不同的进程加载在不同的内存位置"其实是一件很麻烦的事情, 你能想明白为什么吗? 如果你不明白也没关系, 我们会在下一阶段详细讨论这个问题.
为了简单起见, 我们可以在Nanos-lite中直接定义一些测试函数来作为程序, 因为程序本质上就是一些有意义的指令序列, 目前我们不必在意这些指令序列到底从何而来. 不过, 一个需要注意的地方是栈, 我们需要为每个进程分配各自的栈空间.
为什么需要使用不同的栈空间?
如果不同的进程共享同一个栈空间, 会发生什么呢?
反而需要深思熟虑的是第二点, 怎么让执行流在进程之间切换表面上看并不是一件直观的事情.
上下文切换
在PA3中, 我们已经提到了操作系统和用户进程之间的执行流切换, 并介绍了"上下文"的概念: 上下文的本质就是进程的状态. 换句话说, 我们现在需要考虑的是, 如何在多个用户进程之间进行上下文切换.
基本原理
事实上, 有了CTE, 我们就有一种很巧妙的方式来实现上下文切换了.
具体地, 假设进程A运行的过程中触发了系统调用, 陷入到内核.
根据__am_asm_trap()
的代码, A的上下文结构(_Context
)将会被保存到A的栈上.
在PA3中, 系统调用处理完毕之后, __am_asm_trap()
会根据栈上保存的上下文结构来恢复A的上下文.
神奇的地方来了, 如果我们先不着急恢复A的上下文,
而是先将栈顶指针切换到另一个进程B的栈上, 那会发生什么呢?
由于B的栈上存放了之前B保存的上下文结构, 接下来的操作就会根据这一结构来恢复B的上下文.
从__am_asm_trap()
返回之后, 我们已经在运行进程B了!
那进程A到哪里去了呢? 别担心, 它只是被暂时"挂起"了而已. 在被挂起之前, 它已经把上下文结构保存到自己的栈上了, 如果将来的某一时刻栈顶指针被切换到A的栈上, 代码将会根据栈上的上下文结构来恢复A的上下文, A将得以唤醒并执行. 所以, 上下文切换其实就是不同进程之间的栈切换!
进程控制块
但是, 我们要如何找到别的进程的上下文结构呢?
注意到上下文结构是保存在栈上的, 但栈空间那么大,
受到函数调用形成的栈帧的影响, 每次保存上下文结构的位置并不是固定的.
自然地, 我们需要一个cp
指针(context pointer)来记录上下文结构的位置,
当想要找到其它进程的上下文结构的时候, 只要寻找这个进程相关的cp
指针即可.
事实上, 有不少信息都是进程相关的,
除了刚才提到的上下文指针cp
之外, 上文提到的栈空间也是如此.
为了方便对这些进程相关的信息进行管理,
操作系统使用一种叫进程控制块(PCB, process control block)的数据结构, 为每一个进程维护一个PCB.
Nanos-lite的框架代码中已经定义了我们所需要使用的PCB结构(在nanos-lite/include/proc.h
中定义):
typedef union {
uint8_t stack[STACK_SIZE] PG_ALIGN;
struct {
_Context *cp;
};
} PCB;
PCB中还定义了其它成员, 目前可以忽略它们, 我们会在将来介绍它们.
Nanos-lite使用一个联合体来把其它信息放置在进程堆栈的底部.
代码为每一个进程分配了一个32KB的堆栈, 已经足够使用了, 不会出现栈溢出导致UB.
在进行上下文切换的时候, 只需要把PCB中的cp
指针返回给CTE的__am_irq_handle()
函数即可,
剩余部分的代码会根据上下文结构恢复上下文.
我们只要稍稍借助数学归纳法, 就可以让我们相信这个过程对于正在运行的进程来说总是正确的.
那么, 对于刚刚加载完的进程, 我们要怎么切换到它来让它运行起来呢?
内核线程
创建内核线程上下文
答案很简单, 我们只需要在进程的栈上人工创建一个上下文结构, 使得将来切换的时候可以根据这个结构来正确地恢复上下文即可.
上文提到, 我们先把Nanos-lite中直接定义的一些测试函数作为程序.
Nanos-lite提供了一个测试函数hello_fun()
(在nanos-lite/src/proc.c
中定义),
我们接下来的任务就是为它创建一个上下文, 然后切换到它来执行.
这样的执行流有一个专门的名称, 叫"内核线程"(kernel thread).
为什么不叫"内核进程"?
这个问题其实等价于"进程和线程有什么区别", 是个不错的问题. 而且这还属于考研八股的内容呢, 于是你肯定可以通过STFW找到很多五花八门的答案, 比如"线程更加轻量级", "线程没有独立的资源"等等.
如果要进一步解释"什么是轻量级", "独立的资源是什么意思", 在PA中可能比较困难. 不过在PA中也不必深究这个问题, 目前你只需要把它们都看成执行流就可以了, 更重要的是, 这两者你都将会实现, 在代码中亲自去感受它们的区别不是一个更好的选择吗? 另外, 带着这个问题去修读下学期的操作系统课也不错.
创建内核线程的上下文是通过CTE提供的_kcontext()
函数
(在nexus-am/am/src/$ISA/nemu/src/cte.c
中定义)来实现的,
其中的"k"就是代表内核. _kcontext()
的原型是
_Context *_kcontext(_Area kstack, void (*entry)(void *), void *arg);
其中kstack
是栈的范围, entry
是内核线程的入口, arg
则是内核线程的参数.
你需要在kstack
的底部创建一个以entry
为返回地址的上下文结构(目前你可以先忽略arg
参数),
然后返回这一结构的指针. Nanos-lite会调用_kcontext()
来创建上下文,
并把返回的指针记录到PCB的cp
中:
| |
+---------------+ <---- kstack.end
| |
| context |
| |
+---------------+ <--+
| | |
| | |
| | |
| | |
+---------------+ |
| cp | ---+
+---------------+ <---- kstack.start
| |
线程/进程调度
上下文的创建和切换是CTE的工作, 而具体切换到哪个上下文,
则是由操作系统来决定的, 这项任务叫做进程调度.
进程调度是由schedule()
函数(在nanos-lite/src/proc.c
中定义)来完成的,
它用于返回将要调度的进程上下文.
因此, 我们需要一种方式来记录当前正在运行哪一个进程,
这样我们才能在schedule()
中返回另一个进程的上下文, 以实现多任务的效果.
这一工作是通过current
指针(在nanos-lite/src/proc.c
中定义)来实现的,
它用于指向当前运行进程的PCB.
这样, 我们就可以在schedule()
中通过current
来决定接下来要调度哪一个进程了.
不过在调度之前, 我们还需要把当前进程的上下文指针保存在PCB当中:
// save the context pointer
current->cp = prev;
// always select pcb[0] as the new process
current = &pcb[0];
// then return the new context
return current->cp;
目前我们让schedule()
总是切换到pcb[0]
.
注意它的上下文是通过_kcontext()
创建的,
在schedule()
中才决定要切换到它, 然后在CTE的__am_asm_trap()
中才真正地恢复这一上下文.
机制和策略解耦
这其实体现了系统设计中的一种重要原则: 机制和策略解耦. 机制是解决"能不能做"的问题, 策略则是解决"怎么做好"的问题. 显然, 策略需要机制的支撑, 机制需要策略来发挥最大的效果.
解耦的好处就很明显了: 代码重用率高, 而且容易理解. 在Project-N中, 这一解耦几乎做到了极致: 机制和策略被分离到两个子项目中. 比如, "上下文切换"这一机制是在AM的CTE中实现的, 它让我们可以做到"执行流的切换"这件事; 而具体要切换到哪一个执行流, 则是在Nanos-lite中实现的.
AM的另外一个好处是将底层硬件的行为抽象成系统级机制,
AM上的应用(包括OS)只需要调用这些系统级机制, 并实现相应的策略即可.
当然目前schedule()
中的策略非常简单, 下学期的操作系统实验,
甚至是现实中更复杂的进程调度策略, 都可以在AM提供的同一个机制之上实现.
实现上下文切换
根据讲义的上述内容, 实现以下功能:
- CTE的
_kcontext()
函数 - Nanos-lite的
schedule()
函数 - 在Nanos-lite收到
_EVENT_YIELD
事件后, 调用schedule()
并返回新的上下文 - 修改CTE中
__am_asm_trap()
的实现, 使得从__am_irq_handle()
返回后, 先将栈顶指针切换到新进程的上下文结构, 然后才恢复上下文, 从而完成上下文切换的本质操作
你需要在init_proc()
中单独创建一个以hello_fun
为返回地址的上下文:
void init_proc() {
context_kload(&pcb[0], hello_fun, NULL);
switch_boot_pcb();
}
其中, context_kload()
在nanos-lite/src/loader.c
中定义,
它会调用CTE的kcontext()
来创建一个上下文,
而调用switch_boot_pcb()
则是为了初始化current
指针.
如果你的实现正确, 你将会看到hello_fun()
中的输出信息.
需要注意的是, 虽然hello_fun()
带有一个参数arg
,
但目前我们并不使用它, 所以_kontext()
中的arg
参数也可以先忽略,
我们接下来就会考虑它.
一些提示
schedule()
函数很容易, 需要思考的是_kcontext()
.
我们希望代码将来从__am_asm_trap()
返回之后, 就会开始执行hello_fun()
.
换句话说, 我们需要在_kcontext()
中构造一个上下文, 它指示了一个状态,
从这个状态开始, 可以正确地开始执行hello_run()
.
所以你需要思考的是, 为了可以正确地开始执行hello_run()
,
这个状态究竟需要满足什么样的条件?
至于"先将栈顶指针切换到新进程的上下文结构", 很自然的问题就是,
新进程的上下文结构在哪里? 怎么找到它? 又应该怎么样把栈顶指针切换过去?
如果你发现代码跑飞了, 还是老老实实用纸笔模拟__am_asm_trap()
的执行过程吧.
配合DiffTest
如果你选择了x86, 为了保证DiffTest的正确运行, 你需要把上下文结构中的cs
设置为8
.
内核线程的参数
你也许会疑惑, 不就是执行hello_fun()
函数吗, 为什么不能直接调用它,
搞这么复杂, 究竟有什么好处? 现在我们就来展示其中的好处.
还记得hello_fun()
带了一个arg
参数吗? 我们来创建两个内核线程,
给它们传递不同的参数, 然后在输出的信息中把参数也一同输出,
这样我们就能看到执行流在两个内核线程之间来回切换了!
首先需要解决的第一个问题是, 我们要如何通过_kcontext()
给hello_fun()
传参?
于是我们需要继续思考, hello_fun()
将会如何读出它的参数?
噢, 这不就是调用约定的内容吗? 你已经非常熟悉了.
我们只需要让_kcontext()
按照调用约定的内容将arg
放好,
将来hello_fun()
执行的时候就可以获取正确的参数了.
mips32和riscv32的调用约定
我们没有给出mips32和riscv32的调用约定, 你需要查阅相应的ABI手册. 当然, 你也可以自己动手实践来总结传参的规则.
第二个问题是如何在两个内核线程之间来回切换.
hello_fun()
中每次输出完信息都会调用_yield()
,
因此我们只需要对schedule()
进行简单的修改即可:
current = (current == &pcb[0] ? &pcb[1] : &pcb[0]);
实现上下文切换(2)
根据讲义的上述内容, 实现以下功能:
- 修改CTE的
_kcontext()
函数, 使其支持参数arg
的传递 - 修改
hello_fun()
函数, 使其输出参数. 你可以自行约定参数arg
的类型, 包括整数, 字符, 字符串, 指针等皆可, 然后按照你的约定来解析arg
. - 通过
_kcontext()
创建第二个以hello_fun()
为入口的内核线程, 并传递不同的参数 - 修改Nanos-lite的
schedule()
函数, 使其轮流返回两个上下文
如果你的实现正确, 你将会看到hello_fun()
会轮流输出不同参数的信息.
在真实的操作系统中, 内核中的很多背景任务,守护服务和驱动程序都是以内核线程的形式存在的.
如果你执行ps aux
, 你就会看到系统中有很多COMMAND中带有中括号的内核线程(例如[kthreadd]
).
而创建和执行它们的原理, 也是和上面的实验内容非常相似(当然具体实现肯定会有所不同).
保持_kcontext()的特性 (选做)
API设计
用户进程
创建用户进程上下文
创建用户进程的上下文则需要一些额外的考量.
在PA3的批处理系统中, 我们在naive_uload()
中直接通过函数调用转移到用户进程的代码,
那时候使用的还是内核区的栈, 万一发生了栈溢出, 确实会损坏操作系统的数据,
不过当时也只有一个用户进程在运行, 我们也就不追究了.
但在多道程序操作系统中, 系统中运行的进程就不止一个了,
如果让用户进程继续使用内核区的栈, 万一发生了栈溢出,
就会影响到其它进程的运行, 这是我们不希望看到的.
如果内核线程发生了栈溢出, 怎么办?
如果能检测出来, 最好的方法就是触发kernel panic, 因为这时候内核的数据已经不再可信, 如果将一个被改写的数据写会磁盘, 将会造成无法恢复的毁灭性损坏.
好消息是, 内核线程的正确性可以由内核开发人员来保证, 这至少要比保证那些来路不明的用户进程的正确性要简单多了. 而坏消息则是, 大部分的内核bug都是第三方驱动程序导致的: 栈溢出算是少见的了, 更多的是use-after-free, double-free, 还有难以捉摸的并发bug. 而面对海量的第三方驱动程序, 内核开发人员也难以逐一保证其正确性. 如果你想到一个可以提升驱动程序代码质量的方法, 那就是为计算机系统领域作出贡献了.
因此, 和内核线程不同, 用户进程的代码, 数据和堆栈都应该位于用户区,
而且需要保证用户进程能且只能访问自己的代码, 数据和堆栈.
为了区别开来, 我们把PCB中的栈称为内核栈, 位于用户区的栈称为用户栈.
于是我们需要一个有别于_kcontext()
的方式来创建用户进程的上下文,
为此AM额外准备了一个API _ucontext()
(在nexus-am/am/src/$ISA/nemu/src/vme.c
中定义),
它的原型是
_Context *_ucontext(_AddressSpace *as, _Area kstack, void *entry);
其中, 参数as
用于限制用户进程可以访问的内存, 我们在下一阶段才会使用, 目前可以忽略它;
kstack
是内核栈, 用于分配上下文结构, entry
则是用户进程的入口.
由于目前我们忽略了as
参数, 所以_ucontext()
的实现和_kcontext()
几乎一样,
甚至比_kcontext()
更简单: 连参数都不需要传递.
不过你还是需要思考, 对于用户进程来说, 它需要一个什么样的状态来开始执行呢?
咦, 说好的用户栈呢? 事实上, 用户栈的分配是ISA无关的,
所以用户栈相关的部分就交给Nanos-lite来进行, _ucontext()
无需处理.
目前我们让Nanos-lite把heap.end
作为用户进程的栈顶,
然后把这个栈顶赋给用户进程的栈指针寄存器就可以了.
哎呀, 用户进程的栈指针寄存器可是ISA相关的, 在Nanos-lite里面不方便处理.
别着急, 还记得用户进程的那个_start
吗? 在那里可以进行一些ISA相关的操作.
于是Nanos-lite和Navy-apps作了一项约定: Nanos-lite把栈顶位置设置到GPRx中,
然后由Navy-apps里面的_start
来把栈顶位置真正设置到栈指针寄存器中.
在context_uload()
中实现以上内容之后, 我们就可以加载用户进程了.
我们把其中一个hello_fun()
内核线程替换成仙剑奇侠传:
context_uload(&pcb[1], "/bin/pal");
然后我们还需要在serial_write()
, events_read()
和fb_write()
的开头调用_yield()
, 来模拟设备访问缓慢的情况.
添加之后, 访问设备时就要进行上下文切换, 从而实现多道程序系统的功能.
实现多道程序系统
根据上述内容, 实现多道程序系统. 如果你的实现正确, 你将可以一边运行仙剑奇侠传的同时, 一边输出hello信息.
思考一下, 如何验证仙剑奇侠传确实在使用用户栈而不是内核栈?
支持开机菜单程序的运行 (选做)
目前通过开机程序来运行仙剑奇侠传将会出现错误, 你知道为什么吗? 尝试定位并修复这个问题.
可以在用户栈里面创建用户进程上下文吗?
用户进程的参数
我们在实现内核线程的时候, 给它传递了一个arg
参数.
事实上, 用户进程也可以有自己的参数, 那就是你在程序设计课上学习到的argc
和argv
了,
还有一个你也许不怎么熟悉的envp
. envp
是环境变量指针, 它指向一个字符串数组,
字符串的格式都是形如xxx=yyy
, 代表有一个名为xxx
的变量, 它的值为yyy
.
我们在PA0中通过init.sh
初始化PA项目的时候, 它会在.bashrc
文件中定义一些环境变量,
比如AM_HOME
. 当我们编译LiteNES的时候, make
程序就会解析nexus-am/apps/litenes/Makefile
中的内容,
当遇到
include $(AM_HOME)/Makefile.app
的时候, 就会尝试通过getenv()
这个库函数在环境变量指针指向的字符串数组里面寻找是否有形如
AM_HOME=yyy
的字符串, 如果有, 就返回yyy
,
于是make
程序就可以正确地找到nexus-am
项目中的Makefile.app
文件并包含进来.
事实上, main()
函数完整的原型应该是
int main(int argc, char *argv[], char *envp[]);
那么, 当我们在终端里面键入
make ARCH=x86-nemu run
的时候, ARCH=x86-nemu
和run
这两个参数以及环境变量都是怎么传递给make
程序的main()
函数的呢?
既然用户进程是操作系统来创建的, 很自然参数和环境变量的传递就需要由操作系统来负责.
最适合存放参数和环境变量的地方就是用户栈了, 于是操作系统在加载用户进程的时候,
还需要负责把argc/argv/envp
以及相应的字符串放在用户栈中,
并把它们的存放方式和位置作为和用户进程的约定之一, 这样用户进程在_start
中就可以访问它们了.
这项约定其实属于ABI的内容, ABI手册有一节Process Initialization的内容, 里面详细约定了操作系统需要为用户进程的初始化提供哪些信息. 不过在我们的Project-N系统里面, 我们只需要一个简化版的Process Initialization 就够了:
操作系统`argc/argv/envp`及其相关内容放置到用户栈上, 然后将GPRx设置为`argc`的地址.
具体的放置格式可以参考ABI手册, 或者ICS课本第七章的某个图.
阅读ABI手册, 理解计算机系统
事实上, ABI手册是ISA, OS, 编译器, C语言和用户进程的桥梁, 非常值得大家去阅读. ICS课本上那些让你摸不着头脑的约定, 大部分也是出自ABI手册. 你至少也应该去看看ABI手册的目录, 翻一下正文部分的图, 这样你就会对ABI手册有一个大致的了解. 如果你愿意深入推敲一下"为什么这样约定", 那就是真正的"深入理解计算机系统了".
根据这一约定, 你还需要修改Navy-apps中_start
的代码,
在其调用call_main()
之前把它的参数设置成argc
的地址.
然后修改navy-apps/libs/libc/src/plaform/crt0.c
中call_main()
的代码,
让它解析出真正的argc/argv/envp
, 并调用main()
.
这样以后, 用户进程就可以接收到属于它的参数了.
给用户进程传递参数
实现上述内容. 然后修改仙剑奇侠传的少量代码,
如果它接收到一个--skip
参数, 就跳过片头商标动画的播放, 否则不跳过.
商标动画的播放从代码逻辑上具体main()
函数并不远, 于是来RTFSC吧.
不过为了给用户进程传递参数, 你还需要修改context_uload()
的原型:
void context_uload(PCB *pcb, const char *filename, char *const argv[], char *const envp[]);
这样你就可以在init_proc()
中直接给出用户进程的参数来测试了.
目前我们的测试程序中不会用到环境变量, 所以不必传递真实的环境变量字符串.
至于实参应该写什么, 这可是一个C语言的问题, 就交给你来解决吧.
让操作系统为每一个用户进程手动设定参数是一件不现实的事情,
因为归根到底用户进程的参数应该是要让用户来指定的.
于是最好能有一个方法能把用户指定的参数告诉操作系统,
让操作系统来把指定的参数放到新进程的用户栈里面.
这个方法当然就是系统调用SYS_execve
啦, 如果你去看man
, 你会发现它的原型是
int execve(const char *filename, char *const argv[], char *const envp[]);
这也就说明了为什么我们在PA3中通过开机菜单程序运行超级玛丽, 最后还是运行功夫:
我们当时并没有让SYS_execve
给用户进程传递正确的参数!
支持参数的SYS_execve (选做)
修改SYS_execve
的实现, 让其向用户进程传递正确的argv
和envp
.
实现之后, 你就可以通过开机菜单程序正确运行超级玛丽了.
不过你需要先实现上文的"支持开机菜单程序的运行 (选做)".
为什么少了一个const?
在main()
函数中, argv
和envp
的类型是char * []
,
而在execve()
函数中, 它们的类型则是char *const []
.
你知道为什么吗?
用户栈和内核栈
一山不能藏二虎?
尝试把hello_fun()
换成Navy-apps中的hello
:
-context_kload(&pcb[0], (void *)hello_fun);
+context_uload(&pcb[0], "/bin/hello");
你发现了什么问题? 为什么会这样? 思考一下, 答案很快揭晓!
温馨提示
PA4阶段1到此结束.