你管这叫操作系统源码之七
新进程诞生全局概述
一个新进程的诞生,从操作系统的源码角度来说,其实就两行代码。而关于创建进程的重点,其实就一行代码,就是大名鼎鼎的 fork 函数。简单说就是从内核态切换到用户态,然后通过 fork 创建出一个新的进程,再之后老进程进入死循环:
void main(void) {
// 第二部分的内容,各种初始化工作
...
// 第三部分的内容,一个新进程的诞生
move_to_user_mode();
if (!fork()) {
// 新进程里干了啥,是第四部分的内容
init();
}
// 死循环,操作系统怠速状态
for(;;) pause();
}
至于 fork 出来的新进程做了什么事,就是 init 函数里的故事里,这个不在第三部分的讨论范畴。所以你看,一共就两行代码,顶多再算上最后一行的死循环,三行,就把创建新进程这个事搞定了。再加上新进程里要做的 init 函数,一共四行代码,就走到了 main 函数的结尾,也就标志着操作系统启动完毕!
但就是这没有多少个字母的四行代码,是整个操作系统的精髓所在,也是最难的四行代码。理解了它们,你就会有原来操作系统就是这破玩意的感叹了~接下来我们就总览一下这四句,很轻松。
move_to_user_mode
直译过来就是转变为用户态模式。因为 Linux 将操作系统特权级分为用户态与内核态两种,之前都处于内核态,现在要先转变为用户态,仅此而已。
一旦转变为了用户态,那么之后的代码将一直处于用户态的模式,除非发生了中断,比如用户发出了系统调用的中断指令,那么此时将会从用户态陷入内核态,不过当中断处理程序执行完之后,又会通过中断返回指令从内核态回到用户态
整个过程被操作系统的机制拿捏的死死的,始终让用户进程处于用户态运行,必要的时候陷入一下内核态,但很快就会被返回而再次回到用户态,是不是非常无奈?
fork
创建一个新进程的意思,而且所有用户进程想要创建新的进程,都需要调用这个函数。原来操作系统只有一个执行流,就是我们一直看过来的所有代码,就是进程 0,只不过我们并没有意识到它也是一个进程。调用完 fork 之后,现在又多了一个进程,叫做进程 1。
当然,更准确的说法是,我们一路看过来的代码能够被我们自信地称作进程 0 的确切时刻,是我们在系列之六中进程调度初始化 sched_init 里为当前执行流添加了一个进程管理结构到 task 数组里,同时开启了定时器以及时钟中断的那一个时刻。因为此时时钟中断到来之后,就可以执行到我们的进程调度程序,进程调度程序才会去这个 task 数组里挑选合适的进程进行切换。所以此时,我们当前执行的代码,才真正有了一个进程的身份,才勉强得到了一个可以被称为进程 0 的资格,毕竟还没有其他进程参与竞争。
如果你觉得这些话很困惑,就对了,在理解了整个这一块的细节之后,尤其是对于进程调度这种被人赋予了好多虚头巴脑的名词的地方,你会豁然开朗的。
init
只有进程 1 会走到这个分支来执行。这里的代码可太多了,它本身需要完成如加载根文件系统的任务,同时这个方法将又会创建出一个新的进程 2,在进程 2 里又会加载与用户交互的 shell 程序,此时操作系统就正式成为了用户可用的一个状态了。
当然,当你知道了新进程诞生的过程之后,进程 2 的创建,就和进程 1 的创建一样了,在后面的章节中你将不会再困惑创建新进程的过程,减轻了学习负担。所以这一部分,又是作为下一部分的重要基础,环环相扣。
pause
当没有任何可运行的进程时,操作系统会悬停在这里,达到怠速状态。没啥好说的,我一直强调,操作系统就是由中断驱动的一个死循环。
一共四句话,切换到用户态,创建新进程,初始化,然后悬停怠速。乍一看,是不是特别简单?是的,不过当你展开每一段代码的细节后你会发现,一个庞大的世界让你无从下手。但当你把全部细节都捋顺了之后你又会发现,不过如此
从内核态到用户态
让进程无法逃出用户态
我相信你肯定听说过操作系统的内核态与用户态,用户进程都在用户态这个特权级下运行,而有时程序想要做一些内核态才允许做的事情,比如读取硬盘的数据,就需要通过系统调用,来请求操作系统在内核态特权级下执行一些指令。
我们现在的代码,还是在内核态下运行,之后操作系统达到怠速状态时,是以用户态的 shell 进程运行,随时等待着来自用户输入的命令。所以,就在这一步,也就是 move_to_user_mode 这行代码,作用就是将当前代码的特权级,从内核态变为用户态。之后就像上节说的操作系统就掌控了控制权,而用户进程再怎么折腾也无法逃出这个模式。
内核态与用户态的本质-特权级
首先从一个最大的视角来看,这一切都源于 CPU 的保护机制。CPU 为了配合操作系统完成保护机制这一特性,分别设计了分段保护机制与分页保护机制。当我们在系列之二中 进入了保护模式 将 cr0 寄存器的 PE 位开启时,就开启了保护模式,也即开启了分段保护机制
当我们在系列之三中Intel 内存管理两板斧:分段与分页 将 cr0 寄存器的 PG 位开启时,就开启了分页模式,也即开启了分页保护机制
有关特权级的保护,实际上属于分段保护机制的一种。具体怎么保护的呢?由于这里的细节比较繁琐,所以我举个例子简单理解下即可,实际上的特权级检查规则要比我说的多好多内容。
我们目前正在执行的代码地址,是通过 CPU 中的两个寄存器 cs : eip 指向的对吧?cs 寄存器是代码段寄存器,里面存着的是段选择子,还记得系列之二中 进入了保护模式它的结构么?
这里面的低端两位,此时表示 CPL,也就是当前所处的特权级,假如我们现在这个时刻,CS 寄存器的后两位为 3,二进制就是 11,就表示是当前处理器处于用户态这个特权级。假如我们此时要跳转到另一处内存地址执行,在最终的汇编指令层面无非就是 jmp、call 和中断。我们拿 jmp 跳转来举例。如果是短跳转,也就是直接 jmp xxx,那不涉及到段的变换,也就没有特权级检查这回事。如果是长跳转,也就是 jmp yyy : xxx,这里的 yyy 就是另一个要跳转到的段的段选择子结构。这个结构仍然是一样的段选择子结构,只不过这里的低端两位,表示 RPL,也就是请求特权级,表示我想请求的特权级是什么。同时,CPU 会拿这个段选择子去全局描述符表中寻找段描述符,从中找到段基址。
那还记得系列之二中段寄存器的历史包袱段描述符的样子么?
你看,这里面又有个 DPL,这表示目标代码段特权级,也就是即将要跳转过去的那个段的特权级。好了,我们总结一下简图,就是这三个玩意的比较。
这里的检查规则比较多,简单说,绝大多数情况下,要求 CPL 必须等于 DPL,才会跳转成功,否则就会报错。也就是说,当前代码所处段的特权级,必须要等于要跳转过去的代码所处的段的特权级,那就只能用户态往用户态跳,内核态往内核态跳,这样就防止了处于用户态的程序,跳转到内核态的代码段中做坏事。
这只是代码段跳转时所做的特权级检查,还有访问内存数据时也会有数据段的特权级检查,这里就不展开了。最终的效果是,处于内核态的代码可以访问任何特权级的数据段,处于用户态的代码则只可以访问用户态的数据段,这也就实现了内存数据读写的保护。
说了这么多,其实就是,代码跳转只能同特权级,数据访问只能高特权级访问低特权级。
特权级转换方式
诶不对呀,那我们今天要讲的是,从内核态转变为用户态,那如果代码跳转只能同特权级跳,我们现在处于内核态,要怎么样才能跳转到用户态呢?Intel 设计了好多种特权级转换的方式,中断和中断返回就是其中的一种。
处于用户态的程序,通过触发中断,可以进入内核态,之后再通过中断返回,又可以恢复为用户态。就是本节开头的图所表示的。而系统调用就是这么玩的,用户通过 int 0x80 中断指令触发了中断,CPU 切换至内核态,执行中断处理程序,之后中断程序返回,又从内核态切换回用户态。
但有个问题是,我们当前的代码,此时就是处于内核态,并不是由一个用户态程序通过中断而切换到的内核态,那怎么回到原来的用户态呢?答案还是,通过中断返回。没有中断也能中断返回?可以的,Intel 设计的 CPU 就是这样不符合人们的直觉,中断和中断返回的确是应该配套使用的,但也可以单独使用,我们看代码:
void main(void) {
...
move_to_user_mode();
...
}
#define move_to_user_mode() \
_asm { \
_asm mov eax,esp \
_asm push 00000017h \
_asm push eax \
_asm pushfd \
_asm push 0000000fh \
_asm push offset l1 \
_asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
_asm mov ds,ax \
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}
你看,这个方法里直接就执行了中断返回指令 iretd
。那么为什么之前进行了一共五次的压栈操作呢?因为中断返回理论上就是应该和中断配合使用的,而此时并不是真的发生了中断到这里,所以我们得假装发生了中断才行。怎么假装呢?其实就把栈做做工作就好了,中断发生时,CPU 会自动帮我们做如下的压栈操作。而中断返回时,CPU 又会帮我们把压栈的这些值返序赋值给响应的寄存器。
去掉错误码,刚好是五个参数,所以我们在代码中模仿 CPU 进行了五次压栈操作,这样在执行 iretd 指令时,硬件会按顺序将刚刚压入栈中的数据,分别赋值给 SS、ESP、EFLAGS、CS、EIP 这几个寄存器,这就感觉像是正确返回了一样,让其误以为这是通过中断进来的。
压入栈的 CS 和 EIP 就表示中断发生前代码所处的位置,这样中断返回后好继续去那里执行。压入栈的 SS 和 ESP 表示中断发生前的栈的位置,这样中断返回后才好恢复原来的栈。其中,特权级的转换,就体现在 CS 和 SS 寄存器的值里,都是细节!
CS 和 SS 寄存器是段寄存器的一种,段寄存器里的值是段选择子,其结构上面已经提过两遍了,在系列之二中段寄存器的历史包袱 中也专门讲了这个结构的作用。对着结构我们看代码:
#define move_to_user_mode() \
_asm { \
_asm mov eax,esp \
_asm push 00000017h \ ; 给 SS 赋值
_asm push eax \
_asm pushfd \
_asm push 0000000fh \ ; 给 CS 赋值
_asm push offset l1 \
_asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
_asm mov ds,ax \
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}
拿 CS 举例,给它赋的值是,0000000fh,用二进制表示为:0000000000001111,最后两位 11 表示特权级为 3,即用户态。而我们刚刚说了,CS 寄存器里的特权级,表示 CPL,即当前处理器特权级。所以经过 iretd 返回之后,CS 的值就变成了它,而当前处理器特权级,也就变成了用户态特权级。
除了改变特权级之外
除了改变了特权级之外,还做了什么事情呢?刚刚我们关注段寄存器,只关注了特权级的部分,我们再详细看看。
刚刚说了 CS 寄存器为 0000000000001111,最后两位表示用户态的含义。那继续解读,倒数第三位 TI 表示,前面的描述符索引,是从 GDT 还是 LDT 中取,1 表示 LDT,也就是从局部描述符表中取。前面的描述符索引为 1,表示从局部描述符表中取到代码段描述符,如果你熟悉前面我讲过的内容,你将会直接得出上述结论。不过我还是帮你回忆一下。
在系列之六中进程调度初始化,将 0 号 LDT 作为当前的 LDT 索引,记录在了 CPU 的 lldt 寄存器中。
#define lldt(n) __asm__("lldt %%ax"::"a" (_LDT(n)))
void sched_init(void) {
...
lldt(0);
...
}
而整个 GDT 与 LDT 表的设计,经过前面几篇所讲的设计后,成了这个样子:
再看这行代码,把 EIP 寄存器赋值为了那行标号的地址:
void main(void) {
...
move_to_user_mode();
...
}
#define move_to_user_mode() \
_asm { \
_asm mov eax,esp \
_asm push 00000017h \
_asm push eax \
_asm pushfd \
_asm push 0000000fh \
_asm push offset l1 \
_asm iretd /* 执行中断返回指令*/ \
_asm l1: mov eax,17h \
_asm mov ds,ax \
_asm mov es,ax \
_asm mov fs,ax \
_asm mov gs,ax \
}
这里刚好设置的是下面标号 l1 的位置,所以 iretd 之后 CPU 就乖乖去那里执行了。所以其实从效果上看,就是顺序往下执行,只不过利用了 iretd 做了些特权级转换等工作。同理,这里的栈段 ss 和数据段 ds,都被赋值为了 17h,大家可以展开二进制算一下,他们又是什么特权级,对应的描述符又是谁。
小结
所以其实,最终效果上看就是按顺序执行了我们所写的指令,仿佛没有经过什么中断和中断返回的过程,但却通过中断返回实现了特权级的翻转,也就是从内核态变为了用户态,顺便设置了栈段、代码段和数据段的基地址。
好了,我们兜兜转转终于把这个 mov_to_user_mode 讲完了,特权级这块的检查细节非常繁琐,为了理解操作系统,我们只需要暂且记住一句话就好:数据访问只能高特权级访问低特权级,代码跳转只能同特权级跳转,要想实现特权级转换,可以通过中断和中断返回来实现。
OK,我们现在已经进入了用户态,也即表明了需要内核态来完成的工作已经全部安排妥当了,其实就是整个前几篇的内容,对全局描述符表、中断描述符表、页表等关键内存结构进行设置,以及对 CPU 特殊寄存器如 cr0 和 cr3 的设置,还有对外设如硬盘、键盘、定时器的设置等。看来我们又完成了一大堆苦力活呀,内核态做的工作也真是枯燥乏味呢。接下来只需要在用户态进行工作即可了!