转载:http://tieba.baidu.com/p/1273477757
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-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族。
先给出一段伪代码:
有几个部分需要澄清。
首先是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。
到这里,维持生命体征所必要的进程部分基本完备了。
没有涉及到的部分集中在进程队列上,不过比起前面的两种脏活算是小菜一碟。
不过这里一定要注意,使用的全局变量最好限于唯一的内核对象。原因请参考前一节某处。