〔写在OS边上〕定性note
转载:http://tieba.baidu.com/p/1273477757
有的时候我们在读书或者看文档。
——啊,原来这东西的框架就是这样而已,很直白么。
有的时候我们在读代码。
——于是也不免有一点抱怨:作者多写一点注释又不会累死。
(尤其是在作者花了相当篇幅威胁某个禁止他测试DDoS attacker的管理员但懒得多写点解释的情况下。)
不过读完了代码稍微回顾一下的话,又会发现〔其实这里那里的根本就不需要注释〕。
如果只求行尸走肉般的实现,OS也是这么一种东西。
有的时候我会想起以前从某个机房窗口看见延伸到远处的两排路灯,不过可惜现在看不见了。
如果有什么是要顺便提一句的话,那大概是vj也关掉了。
另外有什么让人没法悠闲享受的,说不定就是写代码时候的忐忑吧。
当我写下一个struct iovec的时候确实想着〔嗯,就这样〕,不过一个PCB却总像个Claymore.
也确实一贯被炸得四散飞出去。
那么比我更有觉悟一些的诸君可以看下去了,因为我并不会给出完整的代码,这毕竟也只是玩乐的note.
有人说,tutorial让你〔有信心做下去〕,所以我也不为汗牛充栋的教程再抹一笔黑,想到哪里写哪里而已。
如果希望自制OS, 请在参考一篇tutorial的基础上阅读。厕上亦可。
事先提一下,这里的体系针对i386, 并没有考虑64位机和多CPU的情况,协处理器也被我无视了,请小心鳄鱼。
有关引导、装载和GDT的事情,亚里亚酱的某篇文章里提及过,不再赘述。
手册性质的内容不打算多写了,机制上看来无非是进行一次线性地址到物理地址的映射。
从内核对象的角度看来,一个页目录实体对应一个独立的地址空间,这是构成〔进程〕的基础之一。
如果和只使用一个GDT的角度看来,这样的寻址方式使我们从表示上避开了恶心的段内位移。
——虽然EA仍然是EA, 不过已经可以干净地连续使用了。所以以后我也不会特别提及EA这术语。
(注意到内核/用户代码段和内核/用户数据段的基和限是一样的,配合粒度就可以统一覆盖4GB)
但是另一件事情更麻烦一些。请不要忘记我们还是要执行内核代码、读取内核映像中的数据。
不妨假设内核被加载在了1MB处,延伸向高地址。总之很容易从自定的链接脚本中获得映像的始末地址。
我们需要在完成分页后仍然可以正确找到这些代码或者数据。
一个偷懒的方法,是对内核映像进行一次等值映射,也即位于物理地址p的页被映射到线性地址p.
等值映射的具体方案取决于页面分配的方式和时机,不过无论如何,强行分配页表也一样可行。
对于一个first-fit的页面分配器来说,容易想到在创建页目录对象之后第一时间分配映像页面。
另一个方法在布局上更干净一些,即将物理地址p的页分配到线性地址p+d处。
这样的做法,是为了保证OS的虚拟地址空间布局被完整地划分为用户区与内核区,例如3G/1G布局。
但是优雅的代价,是至少需要修改指令指针。我没有做这样的实现,想来还是在没有栈桢的情况下进行比较好。
除去内核,还有一部分数据是很可能需要等值映射的,即1MB以下的部分。
一个明显的例子是VGA内存映射区。
进行等值映射的时候需要小心:在获取页表项的时候可能创建新页表,这导致需要等值映射的区域被扩张。
(注意到这时我们还没有heap, 毕竟在没有启用分页的时候创建heap并不合适。)
下面是后话:
一个pitfall, 要提醒熟悉fork语义的诸君小心。
请一定记得全局变量是内核映像的一部分,是被等值映射的。
——而内核映像的页面很可能被链接到每个虚拟地址空间,而不是复制过去。
另一个pitfall, 请为用户栈(同时也是实现了cpl3切换之前的中断栈和内核栈)保留一些固定的页面。
并且确保它们在地址空间复制的时候确实被复制了。
(下面的内容,我将不会区分内核栈和中断栈,中断和系统调用共用一个运行时栈)
2 进程(一)
关于进程的定义五花八门,不过总之也脱离不了程序、数据和上下文,想必诸君也有一个版本烂熟于心。
所以要特别指出的只有一点:进程具有独立的虚拟地址空间。
进程的虚拟空间中,只有OS和自身。
如果还记得之前提及的虚拟地址空间布局,大概就能联系起来了。
——OS映像和内核堆被所有进程共享,位于线性地址中的内核区。其他都是自由使用的用户区。
然而也要记得这只是寻址时的福利,致命的页面错误仍然可能把出错的进程拉回某个现实。
另一朵渐欲迷人眼的奇葩是所谓的PCB. 当然我不知为何不太喜欢*CB这种叫法。
PCB中应当包含一系列进程的特征信息和资源信息,以及进程的上下文信息。
上下文信息用于进程的切换,其实也不用太多。基本的切换,保存esp/ebp/cr3就很充分了。
于是按照传统的创世纪步骤,我们需要手工创建第一个进程。
工序很简单:创建一个PCB, 将其初始化,同时使得时钟中断知道自己应当进行上下文切换了。
一个简单的例子,不妨假设系统中只存在就绪队列。
下面是一个比较需要磨合的部分:上下文切换。
首先考虑下我们最需要切换的寄存器:
——esp应当指向上升进程的内核栈顶;
——ebp应当指向上升进程的上一个栈桢;
——cr3应当保存上升进程的页目录,以便正确完成地址映射;
——eip应当指向某个断点,上升进程得以从此继续执行。
然后考虑下切换的顺序:
——因为需要保存下降进程的寄存器上下文,所以cr3的切换时机取决于内核的布局;
——在切换eip之前需要切换到上升进程的esp和ebp;
——esp和ebp的切换顺序取决于PCB中保存的寄存器上下文。
这里提出一个示例方案:
PCB中保存了esp/ebp/cr3/eip四种上下文,但ebp在切换上下文时保存在下降进程的内核栈上。
(至于为何还要在PCB中保存ebp, 后面会有涉及。)
之后的步骤,用很伪的汇编描述像是:
push ebp
mov [下降进程PCB的esp字段], esp
mov dword [下降进程PCB的eip字段], .bpoint
mov cr3, ecx
mov esp, [上升进程PCB的esp字段]
push dword [上升进程PCB的eip字段]
jmp __switch_to
.bpoint:
pop ebp
ret
__switch_to:
ret
直到第七行之前的目的显而易见。此后的push-jmp-ret代码构造了一对call-ret, 使得eip置为上升进程的断点。
如果仅仅是单纯的进程切换,上升进程的断点必然是.bpoint处。另一种情况在fork时发生,后述。
这样的上下文切换使得下降进程进入schedule之后,上升进程从schedule返回。
这个模仿,来自粗口林的实现。他的__switch_to完成了一部分协处理器上下文的处理。
如果觉得有什么不安的地方,也可以在内核栈上保存esi和edi.
下面是另一个或许有点令人困扰的部分。
啊没错,如果诸君还记得某2238行的/* you are not expected to understand this */就更好了。
于是直到现在我还是觉得aret和aretu这样的例程名很帅气的。
关子卖到此为止。下面的实现是fork. 我们需要复制父进程的地址空间,以产生一个新进程。
关于fork的性能也有一些讨论,但是这里都略去不表。既没有写时复制,也没有vfork来配合exec族。
先给出一段伪代码:
cli;
allocate a PCB new_pcb from kernel heap;
set attributes of new_pcb (pid, page directory, status);
bpoint := read_eip();
if (the running process is the parent) then
esp := esp of parent process (not esp from pcb of the parent);
ebp := ebp of parent process (not ebp from pcb of the parent);
new_pcb->esp := esp;
new_pcb->ebp := ebp;
new_pcb->eip := bpoint;
sti;
return new_pcb->pid;
else
mov ebp, new_pcb->ebp;
sti if necessary;
return 0;
有几个部分需要澄清。
首先是read_eip(). 这个例程需要取得的断点bpoint应当是read_eip的返回地址。
如果对之前的call-ret还有印象的话,结合cdecl的约定不难考虑到read_eip应当将返回地址传送给eax.
实现的方式同样不止一种,但pop-jmp的组合是最直白的。
下面考虑实际的执行流程。父进程调用fork的时候,执行read_eip单纯只是赋值而已。
父进程将会补完子进程的PCB, 之后很可能(如果不被切换)单纯地返回。
然而注意到被补完的PCB中,断点信息变成了read_eip的返回地址。
不妨假设目前只有两个进程轮换占有CPU. 父进程的时间片耗尽,在时钟中断上被切换。
这时,__switch_to直接返回到了bpoint := read_eip();之后,仿佛从read_eip返回。
换言之这算是个废止性的返回,上升的子进程并没有pop出ebp, 而是从PCB中取出ebp补完切换。
如果诸君对废止性返回的合理稍微有一点疑问,不妨再考虑下地址空间的状况。
进入schedule的是从fork返回的父进程,而子进程的地址空间只有父进程到fork为止的栈桢。
故而这里子进程处理了schedule剩下的代码反而是个错误,对它来说schedule开始的若干桢是不存在的。
(这些栈桢很可能包括了中断上下文、时钟中断处理例程和调度例程。)
所以有一个pitfall: schedule的处理不应当使用运行时栈存取PCB数据。
或者更直白地说,我们最好采用某种使用少量通用寄存器传参的调用约定声明并实现schedule.
这样我粗糙地论证了一下此处的废止性返回是可用的。于是,子进程按照流程应当返回0。
于是我们在两个地址空间中,观察到了同一调用的两个返回值pid和0。
到这里,维持生命体征所必要的进程部分基本完备了。
没有涉及到的部分集中在进程队列上,不过比起前面的两种脏活算是小菜一碟。
不过这里一定要注意,使用的全局变量最好限于唯一的内核对象。原因请参考前一节某处。