操作系统(jyyOS)_L系列实验报告
L0-hello, bare metal!
没有任何难度。
主要是我想说一下我的离谱实现:直接显示大色块,反正通过了(
除此之外还实现了 klib
的一些函数,包含一个简易版的 printf
,支持除了浮点运算之外的一些基础功能。学到的教训是:在下手写代码之前,要先想好代码的架构,尽可能执行DRY原则,也可以防止为了达成DRY而频繁地修改主要函数的接口。
L1 - pmm
个人感想
- 思维难度:⭐⭐⭐
- 代码难度:⭐⭐⭐⭐
- 消耗时长:⭐⭐⭐⭐⭐⭐
对「一开始先使用一个简单有效的设计」这句话深有感触了,因为不然真的会「陷入Wrong Answer的泥潭」;总共改了 $3$ 版设计,累计写了三四千行代码;算法越改越简单,代码越改越少,通过的点越改越多。
这是我这辈子交过的最痛苦的OJ:
这是最好的一次:
得分情况
通过了所有 Easy Test 和 3 个 Hard Test。最后一个 Hard Test 由于 Kalloc fail on low memory pressure 未通过。
基本设计
在目前提交的版本中,我的设计如下:
- 将所有空间以页为单位进行种类的划分,分别是:slab 页、缓存页、全局页。
- 在初始状态下,每个 cpu 得到一些缓存页,剩余全部为全局页,不存在 slab 页。
- 申请的顺序为:先申请 slab 页,若失败则再申请缓存页,若失败则最后申请全局页。
- 当进行 alloc 请求时,根据申请空间的大小决定申请入口。若申请空间不足1页,则从 slab 页开始申请;若恰好为1页,则从缓存页开始申请;若大于1页,则直接申请全局页。
- 当进行 free 请求时,首先判断其是否来自 slab 页,判断方法为检查 ptr 是否与页框对齐(在分配时保证非整页的分配一定不与页框对齐)。若其来自 slab 页,则归还到 slab 中;反之直接归还到调用 free 的 cpu 的缓存页。因此全局页会不断变少。
slab页机制:
- 一个 slab 页会固定按照一个大小进行划分。根据 slab_size 的不同,每个 cpu 都会保存一系列当前正在使用的 slab 页。
- slab 页在它整个生命周期中顺序地向后分配它掌握的内存。提前归还的内存不会重新分配。
- 当一个 slab 页所有内存全部被分配后,它会下线;当所有内存全部归还后,它会被回收至最后一次调用 free 的 cpu 的缓存页中。
- 当一个 cpu 目前缺少对应大小的 slab 页,但又需要分配它时,它会首先尝试从自己的缓存页中拿出一页变为目标 slab 页并初始化;若该操作失败,则会再从全局页中拿出一个页变为目标 slab 页并初始化。
- 由于 slab 的获取和 slab 的归还操作的是不同的变量,因此可以将 slab 的锁拆开,使得对于同一 slab 的获取和归还可以并发进行。
- slab 页的结构体中,前 128B 用作元数据不进行分配。这为 free 判断内存来源提供了便利(当 free 接受的指针与4096对齐时,则来源一定不为 slab 页,否则来源一定是 slab 页)。
- 以下是 slab 的结构体声明:
1
2
3
4
5
6
7
8
9
10
11
12
13typedef union slab_page {
struct {
int slab_size;
int capacity;
int item_head;
int cnt_used;
int cnt_returned;
int is_online;
spinlock_t lock;
spinlock_t ret_lock;
};
uint8_t data[PAGE_SIZE];
} slab_page;
缓存页与全局页机制:
- 一个 cpu 所拥有的所有缓存页使用一个单向链表维护。
- 全局页的实现较为简陋:维护一个指针,当需要分配时,将指针跳转到相应位置。这会在频繁需要对齐时产生较大浪费;但是当大内存分配非常罕见时,该方法在分配时不会造成过大浪费;尤其是当申请一个页时,可以做到 100% 的返还率,且返还到 cpu 的缓存页中,为之后再次分配降低开销。
- 另外实现了使用块状链表的全局页分配方法。由于效率过低最后没有采纳。
印象深刻的 bug
- 在 2023 年的实验报告中,提到不会存在频繁的 >128B 但不足 4096B 的分配。该约定在今年的 OJ 上完全不成立。由于我将 slab 页的大小设置为 4B~128B,导致略微超过 128B 的请求全部得到了一整个页,导致一直出现 kalloc failed on low memory pressure。
L2 - kmt
个人感想
- 思维难度:⭐⭐⭐⭐
- 代码难度:⭐⭐
- 消耗时间:⭐⭐⭐
是一个比较符合我对「操作系统实验」想象的实验;把一开始的设计做好之后,代码一晚上就敲完了;最后 CPU Starvation 怎么也没查出来原因,本地测试是能够公平调度的,可能是一些 Corner Case 卡住了。
期末考完就一直在算:假设期中期末能拿到 $85%$ 的分,我L2不同的完成程度会带来怎样的总评。在不断回忆L1的痛苦折磨时,还是和自己和解了:只要能过 Easy Test 就算过了。所以第一次(有效的提交)交出 $4/5$ 还是挺开心的。只是没想到最后一个点这么难调……
得分情况
通过了所有 Easy Test 和 2 个 Hard Test。最后一个 Hard Test 由于 CPU starvation 未通过。
基本设计
自旋锁与pmm模块的修改
仿照xv6的代码,让自旋锁提供了 push_off 和 pop_off 的功能,使得上锁过程中中断一定处于关闭状态。
kmt模块和中断模块
任何调度(包括:KMT对于进程的调度、信号量对于睡眠进程的调度与唤醒)的最小单位都是 task,其结构体定义如下:
1 | typedef struct task { |
对于KMT的调度:
- 维护一系列任务链表,任务链表使用
next_task
和prev_task
表示。 - 在调度时,每个CPU仅可能被切换到当前任务所在的任务链表中的其他任务。
- 对于「每个线程绑定在一个CPU上」的设计,为每个CPU单独开一个任务链表。
- 每个CPU都存在一个空转任务
idle
,用于在闲置时进行分配。 - 朴素的调度器实现是:新建任务时插入到
idle
后方,调度时每次选择当前任务在任务链表中的下一个任务作为目标。
对于信号量的调度:
- 维护一个等待链表,用于记录正在共同等待同一个信号量的任务,用
blocked_next
表示。 - 具体见下一节
为了完成以上设计,我设计了 cpustatus 结构体,用于存储每个 CPU 的当前任务和空转任务(静态分配)。
1 | typedef struct cpustatus { |
有关 trap_lk 的设计:
- 我将中断时任何多处理器可访问内存的读写视为临界区,任何触及 cpustat 的行为、在栈上对上下文的操作、对 task_t 结构变量的修改,都会被 trap_lk 保护。这从根本上保证了并发安全性。
- 在 kmt 模块中,对上述的操作体现在
kmt_schedule
和kmt_context_save
时,由于其二者注册为中断处理函数,因此必定从os_trap
处进行调用。考虑到处理中断本身就需要防止并发,不妨直接在os_trap
中给trap_lk
上锁,这把锁同时保护了中断安全和并发安全。这样也就并不需要再在kmt_schedule
和kmt_context_save
函数中给trap_lk
上锁了。
信号量模块
我在信号量的实现中进行了如下设计:
- 如果一个任务需要在
sem_wait
时等待,就将其加入到该信号量的等待链表中。该链表为单向链表,以blocked_next
作为前向指针。加入链表后,设置其状态为STAT_WAIT
,而后yield()
出去让出处理器。由于其状态并非STAT_SLEEP
,因此调度器不可能唤醒他。 - 一个任务在
sem_signal
时会遍历当前信号量的等待链表,并尝试唤醒状态为STAT_WAIT
的任务。这也是状态为STAT_WAIT
的任务被唤醒的唯一方式。
一些思考
空转任务需要定义具体的函数入口和初始化Context吗?(也即,idle线程究竟是什么)
- 不需要。 因为
idle
线程在初始时不会由我们主动进入。我们实际上是在运行os.c
的main
函数中的while(1) { yield(); }
,并强行告诉 CPU:你正在运行的这个线程叫做idle
。因此,当 CPU 第一次被中断时,main
中的while(1) { yield(); }
上下文会被保存到idle
任务的Context
中,因此直接使用 NULL 初始化idle
的参数不会造成影响。
在 os_trap 和 on_irq 的处理时,需要给 irq_handlers 上锁吗?
- 需要。对
irq_handlers
和irq_num
的修改明显是临界区。但这里存在重复拿锁导致死锁的问题:我们可能会在os_trap
的中断处理程序中调用on_irq
注册新的中断。两种解决办法是:- 在
os_trap
中进入中断处理程序前释放irq_lk
,在从处理函数返回后获取irq_lock
- 使用类似于多重关中断的机制防止重复拿锁。但这涉及到
holding()
和spin_lock()
两个语句组合的非原子性,实现起来每个“可嵌套锁”都需要另一个基础的锁来保护这里的原子性,较为复杂。
- 在
在 os_trap 中,需要关中断吗?
- 直觉上需要:在处理中断时再次被中断会导致很糟糕的后果。
- 但经过实验,在
os_trap
内部一直处于关中断模式。我没有扒出来具体的代码,但应该是框架代码在调用os_trap
前进行了关中断的操作。
印象深刻的bug
- (非常建议明年修改/给出更详细的建议) 我认为让
L2
通过编译比通过前4个Tests难多了(((- 由于我新建了一个
common.h
用于存放所有的定义,这导致os.h
里面除了一个#include <common.h>
以外啥都没有。建议以后直接在L0
下发时给出os.h
,推荐同学们在其中实现定义,降低这种迷惑代码出现的可能性。 - 我使用 guard 避免了
common.h
里重复定义的报错,这顺带解决了kernel.h
:仅在common.h
的 guard 范围内去#include <kernel.h>
即可。这也给出了一种不能控制头文件下,使用 guard 的思路:新建一个kernel_wrapper.h
,里面是使用 guard 的#include <kernel.h>
,所有需要引用kernel.h
的地方全部改成kernel_wrapper.h
即可。 - 框架代码没有给出评测机可能会替换的
main.c
的样例,我不清楚他是否会#include <os.h>
,(当然大部分原因也是我使用不熟练)导致我花了很长时间在本地自我内耗,尝试在不#include <os.h>
的前提下,让main.c
中定义的static sem_t sem
能够通过编译。
- 由于我新建了一个
- 我延续
L0/L1
的代码保留了开机时Hello World From CPU #%d
的输出,评测机一直报错thread starvation
,删去该输出后即可正常评测。 - 发现了一个并发bug(已解决,这压根就不是bug):在tty里按住Ctrl+C不放,然后尝试按Alt+2切换tty,会稳定触发直接黑屏,但是不会死机(仍然有调度);继续按住Ctrl+C,然后按Alt+1可以切回去,后面恢复正常。但我对自己的操作系统压测没测出问题。后来发现 Alt+Ctrl+1/2 是 qemu 切换视频输入信号的快捷键……我按了之后会切换一个视图……就顺理成章地黑屏了……