你管这破玩意儿叫操作系统源码(六)
本文为学习操作系统源码 (低并发编程)所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。
第二十一回 | 一个新进程的诞生(一)
到了第三部分,简单说就是从内核态切换到用户态,然后通过 fork 创建出一个新的进程,再之后老进程进入死循环。
1 |
|
第一句是 move_to_user_mode
直译过来即可,就是转变为用户态模式。因为 Linux 将操作系统特权级分为用户态与内核态两种,之前都处于内核态,现在要先转变为用户态,仅此而已。
一旦转变为了用户态,那么之后的代码将一直处于用户态的模式,除非发生了中断,比如用户发出了系统调用的中断指令,那么此时将会从用户态陷入内核态,不过当中断处理程序执行完之后,又会通过中断返回指令从内核态回到用户态。
第二句是 fork
这是创建一个新进程的意思,而且所有用户进程想要创建新的进程,都需要调用这个函数。
原来操作系统只有一个执行流,就是我们一直看过来的所有代码,就是进程 0,只不过我们并没有意识到它也是一个进程。调用完 fork 之后,现在又多了一个进程,叫做进程 1。
当然,更准确的说法是,我们一路看过来的代码能够被我们自信地称作进程 0 的确切时刻,是我们在 第18回 | 进程调度初始化 sched_init 里为当前执行流添加了一个进程管理结构到 task 数组里,同时开启了定时器以及时钟中断的那一个时刻。
因为此时时钟中断到来之后,就可以执行到我们的进程调度程序,进程调度程序才会去这个 task 数组里挑选合适的进程进行切换。所以此时,我们当前执行的代码,才真正有了一个进程的身份,才勉强得到了一个可以被称为进程 0 的资格,毕竟还没有其他进程参与竞争。
第三句是 init
只有进程 1 会走到这个分支来执行。这里的代码可太多了,它本身需要完成如加载根文件系统的任务,同时这个方法将又会创建出一个新的进程 2,在进程 2 里又会加载与用户交互的 shell 程序,此时操作系统就正式成为了用户可用的一个状态了。
第四句是 pause
当没有任何可运行的进程时,操作系统会悬停在这里,达到怠速状态。没啥好说的,我一直强调,操作系统就是由中断驱动的一个死循环。
第二十二回 | 一个新进程的诞生(二) 从内核态到用户态
1 |
|
今天我们就重点讲这第一句代码,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 就是这样不符合人们的直觉,中断和中断返回的确是应该配套使用的,但也可以单独使用,我们看代码。
1 |
|
你看,这个方法里直接就执行了中断返回指令 iretd。
那么为什么之前进行了一共五次的压栈操作呢?因为中断返回理论上就是应该和中断配合使用的,而此时并不是真的发生了中断到这里,所以我们得假装发生了中断才行。
怎么假装呢?其实就把栈做做工作就好了,中断发生时,CPU 会自动帮我们做如下的压栈操作。而中断返回时,CPU 又会帮我们把压栈的这些值返序赋值给响应的寄存器。
去掉错误码,刚好是五个参数,所以我们在代码中模仿 CPU 进行了五次压栈操作,这样在执行 iretd 指令时,硬件会按顺序将刚刚压入栈中的数据,分别赋值给 SS、ESP、EFLAGS、CS、EIP 这几个寄存器,这就感觉像是正确返回了一样,让其误以为这是通过中断进来的。
CS 和 SS 寄存器是段寄存器的一种,段寄存器里的值是段选择子,其结构上面已经提过两遍了,在 第六回 | 先解决段寄存器的历史包袱问题 中也专门讲了这个结构的作用。
对着这个结构,我们看代码。
1 |
|
拿 CS 举例,给它赋的值是,0000000fh,用二进制表示为:
0000000000001111
最后两位 11 表示特权级为 3,即用户态。而我们刚刚说了,CS 寄存器里的特权级,表示 CPL,即当前处理器特权级。
所以经过 iretd 返回之后,CS 的值就变成了它,而当前处理器特权级,也就变成了用户态特权级。
刚刚说了 CS 寄存器为 0000000000001111,最后两位表示用户态的含义。
那继续解读,倒数第三位 TI 表示,前面的描述符索引,是从 GDT 还是 LDT 中取,1 表示 LDT,也就是从局部描述符表中取。
在 第18回 | 大名鼎鼎的进程调度就是从这里开始的 中,将 0 号 LDT 作为当前的 LDT 索引,记录在了 CPU 的 lldt 寄存器中。
1 |
|
而整个 GDT 与 LDT 表的设计,经过整个 第一部分 进入内核前的苦力活 和 第二部分 大战前期的初始化工作 的设计后,成了这个样子。
再看这行代码,把 EIP 寄存器赋值为了那行标号的地址。
1 |
|
这里刚好设置的是下面标号 l1 的位置,所以 iretd 之后 CPU 就乖乖去那里执行了。所以其实从效果上看,就是顺序往下执行,只不过利用了 iretd 做了些特权级转换等工作。
第二十三回 | 一个新进程的诞生(三) 如果让你来设计进程调度
进程调度本质是什么?很简单,假如有三段代码被加载到内存中。
进程调度就是让 CPU 一会去程序 1 的位置处运行一段时间,一会去程序 2 的位置处运行一段时间。
整体流程设计
第一种办法就是,程序 1 的代码里,每隔几行就写一段代码,主动放弃自己的执行权,跳转到程序 2 的地方运行。然后程序 2 也是如此。
第二种办法就是,由一个不受任何程序控制的,第三方的不可抗力,每隔一段时间就中断一下 CPU 的运行,然后跳转到一个特殊的程序那里,这个程序通过某种方式获取到 CPU 下一个要运行的程序的地址,然后跳转过去。
这个每隔一段时间就中断 CPU 的不可抗力,就是由定时器触发的时钟中断。
不知道你是否还记得,这个定时器和时钟中断,早在 第18回 | 大名鼎鼎的进程调度就是从这里开始的 里讲的 sched_init 函数里就搞定了。
而那个特殊的程序,就是具体的进程调度函数了。
上下文环境
每个程序最终的本质就是执行指令。这个过程会涉及寄存器,内存和外设端口。
内存还有可能设计成相互错开的,互不干扰,比如进程 1 你就用 0~1K 的内存空间,进程 2 就用 1K~2K 的内存空间,咱谁也别影响谁。
虽然有点浪费空间,而且对程序员十分不友好,但起码还是能实现的。
不过寄存器一共就那么点,肯定做不到互不干扰,可能一个进程就把寄存器全用上了,那其他进程咋整。
所以最稳妥的做法就是,每次切换进程时,都把当前这些寄存器的值存到一个地方,以便之后切换回来的时候恢复。
Linux 0.11 就是这样做的,每个进程的结构 task_struct 里面,有一个叫 tss 的结构,存储的就是 CPU 这些寄存器的信息。
1 |
|
你发现 tss 结构里还有个 cr3 不?它表示 cr3 寄存器里存的值,而 cr3 寄存器是指向页目录表首地址的。
那么指向不同的页目录表,整个页表结构就是完全不同的一套,那么线性地址到物理地址的映射关系就有能力做到不同。
也就是说,在我们刚刚假设的理想情况下,不同程序用不同的内存地址可以做到内存互不干扰。
但是有了这个 cr3 字段,就完全可以无需由各个进程自己保证不和其他进程使用的内存冲突,因为只要建立不同的映射关系即可,由操作系统来建立不同的页目录表并替换 cr3 寄存器即可。
这也可以理解为,保存了内存映射的上下文信息。
运行时间信息
所以一个好的办法就是,给进程一个属性,叫剩余时间片,每次时钟中断来了之后都 -1,如果减到 0 了,就触发切换进程的操作。
在 Linux 0.11 里,这个属性就是 counter。
1 |
|
而他的用法也非常简单,就是每次中断都判断一下是否到 0 了。
而他的用法也非常简单,就是每次中断都判断一下是否到 0 了。
1 |
|
如果还没到 0,就直接返回,相当于这次时钟中断什么也没做,仅仅是给当前进程的时间片属性做了 -1 操作。
如果已经到 0 了,就触发进程调度,选择下一个进程并使 CPU 跳转到那里运行。
优先级
1 |
|
每次一个进程初始化时,都把 counter 赋值为这个 priority,而且当 counter 减为 0 时,下一次分配时间片,也赋值为这个。
进程状态
那这个状态可以记录一个属性了,叫 state,记录了此时进程的状态。
1 |
|
而这个进程的状态在 Linux 0.11 里有这么五种。
1 |
|
好了,目前我们这几个字段,就已经可以完成简单的进程调度任务了。
有表示状态的 state,表示剩余时间片的 counter,表示优先级的 priority,和表示上下文信息的 tss。
第二十四回 | 一个新进程的诞生(四) 从一次定时器滴答来看进程调度
还记得我们在 第18回 | 大名鼎鼎的进程调度就是从这里开始的 sched_init 的时候,开启了定时器吧?这个定时器每隔一段时间就会向 CPU 发起一个中断信号。
这个间隔时间被设置为 10 ms,也就是 100 Hz。
1 |
|
发起的中断叫时钟中断,其中断向量号被设置为了 0x20。
还记得我们在 sched_init 里设置的时钟中断和对应的中断处理函数吧?
1 |
|
这样,当时钟中断,也就是 0x20 号中断来临时,CPU 会查找中断向量表中 0x20 处的函数地址,即中断处理函数,并跳转过去执行。
这个中断处理函数就是 timer_interrupt,是用汇编语言写的。
1 |
|
这个函数做了两件事,一个是将系统滴答数这个变量 jiffies 加一,一个是调用了另一个函数 do_timer。
1 |
|
首先将当先进程的时间片 -1,然后判断:
如果时间片仍然大于零,则什么都不做直接返回。
如果时间片已经为零,则调用 schedule(),很明显,这就是进行进程调度的主干。
1 |
|
不严谨的简化
1 |
|
很简答,这个函数就做了三件事:
1. 拿到剩余时间片(counter的值)最大且在 runnable 状态(state = 0)的进程号 next。
2. 如果所有 runnable 进程时间片都为 0,则将所有进程(注意不仅仅是 runnable 的进程)的 counter 重新赋值(counter = counter/2 + priority),然后再次执行步骤 1。
3. 最后拿到了一个进程号 next,调用了 switch_to(next) 这个方法,就切换到了这个进程去执行了。
看 switch_to 方法,是用内联汇编语句写的。
1 |
|
这段话就是进程切换的最最最最底层的代码了。
主要就干了一件事,就是 ljmp 到新进程的 tss 段处。
CPU 规定,如果 ljmp 指令后面跟的是一个 tss 段,那么,会由硬件将当前各个寄存器的值保存在当前进程的 tss 中,并将新进程的 tss 信息加载到各个寄存器。
上图来源于《Linux内核完全注释V5.0》
这个图在完全注释这本书里里画的非常清晰,我就不重复造轮子了。
简单说就是,保存当前进程上下文,恢复下一个进程的上下文,跳过去!
罪魁祸首的,就是那个每 10ms 触发一次的定时器滴答。
而这个滴答将会给 CPU 产生一个时钟中断信号。
而这个中断信号会使 CPU 查找中断向量表,找到操作系统写好的一个时钟中断处理函数 do_timer。
do_timer 会首先将当前进程的 counter 变量 -1,如果 counter 此时仍然大于 0,则就此结束。
但如果 counter = 0 了,就开始进行进程的调度。
进程调度就是找到所有处于 RUNNABLE 状态的进程,并找到一个 counter 值最大的进程,把它丢进 switch_to 函数的入参里。
switch_to 这个终极函数,会保存当前进程上下文,恢复要跳转到的这个进程的上下文,同时使得 CPU 跳转到这个进程的偏移地址处。
接着,这个进程就舒舒服服地运行了起来,等待着下一次时钟中断的来临。