NJU计算机课程基础实验 (完) 2022-09-16
虚实交错的魔法: 分时多任务
从这我越来越感受到系统复杂度上升带来的挑战,也明白了抽象的根本目的。
抽象是为了降低复杂度,为了系统能够更好的做大以及稳健和排错。
而且抽象能让你切换不同的“后续程序”进行测试,甚至在大概率正确的载体上进行diff查看到底是哪一层出现了问题(参考各种native)
如何相信抽象是对的?——对每一个抽象层完成后的充分测试。
善用assert是魔法!
(个人能力受限,挂在了2阶的最后pal阶段。。。其实基本也算是做完了.也许有机会能请教大佬解决一下,这样就可以做第三阶段了,很可惜,但也只能这样了。)
2022年9月16日记
上下文切换
自从有了上下文切换后,程序也就有了进程的概念。(从静止到运动的飞跃 ~~·)
假设进程A运行的过程中触发了系统调用, 陷入到内核. 根据
__am_asm_trap()
的代码, A的上下文结构(Context
)将会被保存到A的栈上. 在PA3中, 系统调用处理完毕之后,__am_asm_trap()
会根据栈上保存的上下文结构来恢复A的上下文.
如果我们先不着急恢复A的上下文, 而是先将栈顶指针切换到另一个进程B的栈上, 那会发生什么呢? 由于B的栈上存放了之前B保存的上下文结构, 接下来的操作就会根据这一结构来恢复B的上下文. 上下文切换其实就是不同进程之间的栈切换!
进程控制块
有不少信息都是进程相关的, 除了刚才提到的上下文指针
cp
之外, 上文提到的栈空间也是如此. 为了方便对这些进 程相关的信息进行管理, 操作系统使用一种叫进程控制块(PCB, process control block)的数据结构, 为每一个进程维护一个PCB. Nanos-lite使用一个联合体来把其它信息放置在进程堆栈的底部. 代码为每一个进程分配了一个32KB的堆栈, 已经足够使用了, 不会出现栈溢出导致UB. 在进行上下文切换的时候, 只需要把PCB中的cp
指针返回给CTE的__am_irq_handle()
函数即可, 剩余部分的代码会根据上下文结构恢复上下文.
内核线程
对于刚刚加载完的进程, 我们要怎么切换到它来让它运行起来呢?? 答案很简单, 我们只需要在进程的栈上人工创建一个上下文结构, 使得将来切换的时候可以根据这个结构来正确地恢复上下文即可. 我们先把Nanos-lite中直接定义的一些测试函数作为程序. Nanos-lite提供了一个测试函数
hello_fun()
(在nanos-lite/src/proc.c
中定义), 我们接下来的任务就是为它创建一个上下文, 然后切换到它来执行. 这样的执行流有一个专门的名称, 叫"内核线程"(kernel thread).创建内核线程的上下文是通过CTE提供的
kcontext()
函数 (在abstract-machine/am/src/$ISA/nemu/cte.c
中定义)来实现的, 在Nanos-lite中, 我们可以通过一个context_kload()
函数来进行进一步的封装: 它会调用kcontext()
来创建上下文, 并把返回的指针记录到PCB的cp
中上下文的创建和切换是CTE的工作, 而具体切换到哪个上下文, 则是由操作系统来决定的, 这项任务叫做进程调度 进程调度是由
schedule()
函数(在nanos-lite/src/proc.c
中定义)来完成的, 它用于返回将要调度的进程上下文. 因此, 我们需要一种方式来记录当前正在运行哪一个进程, 这样我们才能在schedule()
中返回另一个进程的上下文我们让
schedule()
总是切换到pcb[0]
. 注意它的上下文是通过kcontext()
创建的, 在schedule()
中才决定要切换到它, 然后在CTE的__am_asm_trap()
中才真正地恢复这一上下文.
努力理解了一下,然后写了个汇编,成功点亮~(主要卡在kcontex如何传送area)
这里还要思考一下area到底怎么才是对的。开头地址kstack.start应该拿谁?那段空间应该从哪边开始界定(这也是需要理解的坑)
内核线程的参数
我们来创建两个内核线程, 给它们传递不同的参数, 然后在输出的信息中把参数也一同输出, 这样我们就能看到执行流在两个内核线程之间来回切换了! 我们只需要让
kcontext()
按照调用约定将arg
放置在正确的位置, 将来hello_fun()
执行的时候就可以获取正确的参数了.
感叹这个设计真聪明~
实现上下文切换(2)
根据讲义的上述内容, 实现以下功能:
修改CTE的
kcontext()
函数, 使其支持参数arg
的传递通过
kcontext()
创建第二个以hello_fun()
为入口的内核线程, 并传递不同的参数修改Nanos-lite的
schedule()
函数, 使其轮流返回两个上下文你可以自行约定用何种类型来解析参数
arg
(整数, 字符, 字符串, 指针等皆可), 然后修改hello_fun()
中的输出代码, 来按照你约定的方式解析arg
. 如果你的实现正确, 你将会看到hello_fun()
会轮流输出不同参数的信息.
这里要想想,riscv用什么方法传参呢?然后思考一下会不会和我们之前的操作有没有冲突(本质是执行顺序的问题,实际上没任何问题)
为什么这里叫做内核线程?我的想法是因为他在nanos内部调用实现,并且可以达到快速的执行流切换的效果。
在真实的操作系统中, 内核中的很多后台任务, 守护服务和驱动程序都是以内核线程的形式存在的. 如果你执行
ps aux
, 你就会看到系统中有很多COMMAND中带有中括号的内核线程(例如[kthreadd]
). 而创建和执行它们的原理, 也是和上面的实验内容非常相似(当然具体实现肯定会有所不同).
有关一些调用约定:
用户进程
创建用户进程上下文
在PA3的批处理系统中, 我们在
naive_uload()
中直接通过函数调用转移到用户进程的代码, 那时候使用的还是内核区的栈
怎么知道的?因为naive_uload跳转的entry本质是在nanos的时候调用函数,出入栈都在内部完成。
如果内核线程发生了栈溢出, 怎么办?
如果能检测出来, 最好的方法就是触发kernel panic, 因为这时候内核的数据已经不再可信, 如果将一个被破坏的数据写回磁盘, 将会造成无法恢复的毁灭性损坏.
好消息是, 内核线程的正确性可以由内核开发人员来保证, 这至少要比保证那些来路不明的用户进程的正确性要简单多了. 而坏消息则是, 大部分的内核bug都是第三方驱动程序导致的: 栈溢出算是少见的了, 更多的是use-after-free, double-free, 还有难以捉摸的并发bug. 而面对海量的第三方驱动程序, 内核开发人员也难以逐一保证其正确性. 如果你想到一个可以提升驱动程序代码质量的方法, 那就是为计算机系统领域作出贡献了.
Nanos-lite和Navy作了一项约定: Nanos-lite把栈顶位置设置到GPRx中, 然后由Navy里面的
_start
来把栈顶位置真正设置到栈指针寄存器中.
这里我实现后一直出不来。。(首先记得不能在init加载程序了)
最后发现原来是局部变量和全局的问题(我把参数设置在init,init结束后在stack上的参数自动没了。。。所以就找不到了),最好的方法是直接传入或者是全局变量
此时有点“一起卡”的感觉,本质是因为pal 的屏幕io的时候就切换去跑hello了。(yield)
问:如何验证仙剑奇侠传确实在使用用户栈而不是内核栈?
只要获取pal内部的地址信息在什么范围即可。
还好检查了一下这个,一看发现我的pal还是在内核,直接翻车- -
后面思考了一下发现应该是stack分配的问题。。这点上讲义确实不骗人 老老实实实现就行了。
认真注意这句话:“目前我们让Nanos-lite把heap.end
作为用户进程的栈顶, 然后把这个栈顶赋给用户进程的栈指针寄存器就可以了.”
用户进程的参数
最适合存放参数和环境变量的地方就是用户栈了, 因为在首次切换到用户进程的时候, 用户栈上的内容就已经可以被用户进程访问. 于是操作系统在加载用户进程的时候, 还需要负责把
argc/argv/envp
以及相应的字符串放在用户栈中, 把它们的存放方式和位置作为和用户进 程的约定之一, 这样用户进程在_start
中就可以根据约定访问它们了.https://github.com/riscv-non-isa/riscv-elf-psabi-doc ABI手册有一节Process Initialization的内容, **里面详细约定了操作系统需要为用户进程的初始化提供哪些信息. **不过在我们的Project-N系统里面, 我们只需要一个简化版的Process Initialization就够了: 操作系统将
argc/argv/envp
及其相关内容放置到用户栈上, 然后将GPRx设置为argc
所在的地址.
这里遇到了一个问题,数组一旦作为参数传入后就退化成了指针,我们怎么才能求得正确的大小呢?一个机智的方式是在末尾留个NULL(很多东西都参考了这个设计),这样就可以随意遍历求得数组大小了。
每次操作差点忘记操作栈顶导致翻车,一顿操作猛如虎安排好了用户栈的空间状况,成功跳过开头。(pal的代码写的确实比较通俗)
实现带参数的SYS_execve
用户进程的参数还是应该由用户来指定的.最好能有一个方法能把用户指定的参数告诉操作系统,
让操作系统来把指定的参数放到新进程的用户栈里面.
为了实现带参数的
SYS_execve
, 我们可以在sys_execve()
中直接调用context_uload()
. 但我们还需要考虑如下的一些细节, 为了方便描述, 我们假设用户进程A将要通过SYS_execve
来执行另一个新程序B.
如何在A的执行流中创建用户进程B?
如何结束A的执行流?
我们可以从栈底(
heap.end
)到栈顶(栈指针sp
当前的位置)列出用户栈中的内容:
Nanos-lite之前为A传递的用户进程参数(
argc/argv/envp
)A从
_start
开始进行函数调用的栈帧, 这个栈帧会一直生长, 直到调用了libos中的execve()
CTE保存的上下文结构, 这是由于A在
execve()
中执行了系统调用自陷指令导致的Nanos-lite从
__am_irq_handle()
开始进行函数调用的栈帧, 这个栈帧会一直生长, 直到调用了SYS_execve
的系统调用处理函数通过上述分析, 我们得出一个重要的结论: 在加载B时, Nanos-lite使用的是A的用户栈! 这意味着在A的执行流结束之前, A的用户栈是不能被破坏的. 因此
heap.end
附近的用户栈是不能被B复用的, 我们应该申请一段新的内存作为B的用户栈可以让
context_uload()
统一通过调用new_page()
函数来获得用户栈的内存空间.new_page()
函数在nanos-lite/src/mm.c
中定义, 它会通过一个pf
指针来管理堆区, 用于分配一段大小为nr_page * 4KB
的连续内存区域, 并返回这段区域的首地址. 我们让context_uload()
通过new_page()
来分配32KB的内存作为用户栈, 这对PA中的用户程序来说已经足够使用了.操作系统作为一个特殊的AM应用, 很多时候对动态内存申请却有更严格的要求, 例如申请一段起始地址是4KB整数倍的内存区域,
malloc()
通常不能满足这样的要求. 因此操作系统一般都会自己来管理堆区, 而不会调用klib中的malloc()
. 在操作系统中管理堆 区是MM(Memory Manager)模块的工作
一开始理解错了什么是A还以为hello是A。。。。。然后被提醒后看了好几遍发现原来是这个意思= = (联系一下PA的上文,搞清楚到底是什么进程,这个进程在当下是什么,要换成什么进程)
一顿操作猛如虎成功:
这里运行nterm又挂了(其他都可以)我哭了- - 又开始排查问题,发现传入argc就会挂。。。具体原因不明,又要继续排查。最后发现是argv飞掉的问题(没有参数的时候如果传入指针拦不住)
总结:你必须搞清楚上下文是怎么从创建到调度的全过程,这样才能理解关键的每一个细节。
运行Busybox
我的不知道为啥遇到了致命的ld错误。。。。没办法跑
只能放弃了(很神奇的ld符号缺失)
虽然我后面暴力调整了源码可以编译通过了- -但是只能cat之类的基础功能。。。
然后不知道为什么让虚拟机走了几次快照,就可以用了- -只能说计算机真神奇呀。。。
WC也完美运行~
—skip也同理完结~ 这阶段最主要的就是麻烦。。很多时候可能一不小心argv就会爆炸。
第一阶段结束,这一阶段的关键就是1:上下文执行流是怎么保存和切换的 2:有关参数的指针小练习
以下几个问题:
- 终端如何读取用户的按键?
各种等待键盘事件。。。
- Shell如何进行命令的解析?
可以类似eval,然后要注意argv的维护
- 库函数如何根据命令解析出的字符串搜索到可执行文件?
execvp,维护了一个PATH
- 操作系统如何加载执行一个可执行文件?
维护参数表进入一个_start,处理后才进行真正的main(上下文切换和跳转)
程序和内存位置(很感人的阅读环节)
绝对代码
一般来说, 程序的内存位置是在链接时刻(link time)确定的(Navy-apps中的程序就是这样), 以前的程序员甚至在程序中使用绝对地址来进行内存访问, 这两种代码称为绝对代码(absolute code).
绝对代码会假设程序中的对象(函数和数据)位于某个固定的位置, 绝对代码只能在固定的内存位置才能正确运行.
操作系统在加载时刻分配的空闲内存位置, 并不总是能让这种程序正确运行.
因此, 这个问题的一个解决方案, 就是让操作系统记录程序的加载位置, 当一个程序试图加载到一个已经被使用的内存位置时, 加载将会失败, 操作系统将返回 一个错误. 为了避免加载失败, 一个方法是为每个程序维护多个不同加载地址的版本, 期望其中有一个版本可以被成功加载.
可重定位代码
为什么一定要提前确定一个程序的加载位置呢? 如果我们把链接时的重定位阶段往后推迟, 不就可以打破绝对代码的限制了吗?
于是有程序员开发了一类"自重定位(self-relocation)"的特殊程序, 这种程序可以**在开始运行的时候, 先把自己重定位到其它内存位置, **然后再开始真正的运行. 这种重定位类型称为"运行时(run time)重定位".
但对多任务操作系统来说, 这并没有真正解决问题, 因为程序在运行时刻并不知道重定位的目标内存位置是否空闲.
既然只有操作系统才知道内存是否空闲, 那就干脆让加载器来进行重定位吧, 于是有了"加载时(load time)重定位"的说法.
具体地, 加载器会申请一个空闲的内存位置, 然后将程序加载到这个内存位置, 并把程序重定位到这个内存位置, 之后才会执行这个程序. 今天的GNU/Linux就是通过这种方式来插入