第2章 操作系统是如何工作的
1 计算机的三大法宝
存储程序计算机:冯诺依曼结构
函数调用堆栈机制:记录调用的路径和参数的空间
中断机制:由CPU和内核代码共同实现了保存现场和恢复现场,把ebp,esp,eip寄存器的数据push到内核堆栈中。再把eip指向中断程序的入口,保存现场。
EBP(基址指针)寄存器在C语言中用作记录当前函数调用的基址,如果当前函数调用比较深,每一个函数的EBP是不一样的。函数调用堆栈就是由多个逻辑上的堆栈叠起来的框架,利用这样的堆栈框架实现函数的调用和返回。
对于x86体系结构来讲,堆栈空间是从高地址向低地址增长的,如下图所示:
2 在mykernel基础上构造一个简单的操作系统内核
C代码中内嵌汇编的语法如下:
_asm_ _volatile_ (
汇编语句模板:
输出部分:
输入部分:
破坏描述部分
);
这里可以把内嵌汇编当作一个函数,则第二部分输出和第三部分输入相当于函数的参数和返回值,而第一部分的汇编代码则相当于函数内部的具体代码。
构造一个简单的操作系统内核为:
第一步:增加一个mypcb.h头文件,用来定义进程控制块(Process Control Block),也就是进程结构体的定义,在LInux内核中是struct tast_struct结构体,如下图所示:
第二步:对mymain.c进行修改,这里是mykernel内核代码的入口,负责初始化内核的各个组成部分。在Linux内核源代码中,实际的内核入口是init/main.c中的start_kernel(void)函数。如下图所示:
第三步:对myinterrupt.c进行修改,主要是增加了进程切换的代码my_schedule(void)函数,在Linux内核源代码中对应的是schedule(void)函数。如下图所示:
最后,用cd ..命令返回上一层目录linux-3.9.4下,输入make命令重新编译,再输入qemu -kernel arch/x86/boot/bzImage,就可以看到重新编译后的内核启动效果如下图所示:
从图中可看到进程在切换。
3 代码分析
mypcb.h:此文件主要定义了一个PCB结构体,也就是所谓的进程管理块,用来记录进程的有关信息。
mymain.c:该文件就是完成了内核的初始化工作,并且创建了4个进程,进程从0号开始执行,并且根据标志位判断进程是否需要调度,此时会执行my_schedule()方法来完成相应的调度。
myinterrupt:此文件主要是产生时钟中断,用一个时间计数器周期性的检查循环条件,当条件满足时便会产生中断,并将进程调度标志位置1,当标志位为1时,进程便会执行my_schedule()方法,首先将当前进程的信息通过嵌入式汇编语句保存到堆栈当中,然后将下一个进程的地址赋给当前运行程序的指针,完成调度。
启动第一个进程即0号进程的关键汇编代码为:
1 asm volatile(
2 "movl %1,%%esp
" /* set task[pid].thread.sp to esp */
3 "pushl %1
" /* push ebp */
4 "pushl %0
" /* push task[pid].thread.ip */
5 "ret
" /* pop task[pid].thread.ip to eip */
6 :
7 : "c" (task[pid].thread.ip),"d" (task[pid].thread.sp) /* input c or d mean %ecx/%edx*/
8 );
这里需要注意的是%1是指后面的“ “d”(task[pid].thread.sp)”,%0是指后面的“ “c”(task[pid].thread.ip)”,进程0被初始化时,进程0的堆栈和相关寄存器的变化过程如下图所示:
进程调度代码如下:
1 if(next->state == 0)/* next->state == 0对应进程next对应进程曾经执行过 */
2 {
3 /* 进行进程调度关键代码 */
4 asm volatile(
5 "pushl %%ebp
" /* 保存当前EBP到堆栈中 */
6 "movl %%esp,%0
" /* 保存当前ESP到当前进程PCB中 */
7 "movl %2,%%esp
" /* 将next进程的堆栈栈顶的值存到ESP寄存器中 */
8 "movl $1f,%1
" /* 保存当前进程的EIP值,下次恢复进程后将在标号1开始执行 */
9 "pushl %3
" /* 将next进程继续执行的代码位置(标号1)压栈 */
10 "ret
" /* 出栈标号 1到EIP寄存器*/
11 "1: " /* 标号1,即next进程开始执行的位置 */
12 "popl %%ebp
" /* 恢复EBP寄存器的值 */
13 : "=m" (prev->thread.sp),"=m" (prev->thread.ip)
14 : "m" (next->thread.sp),"m" (next->thread.ip)
15 );
16 }
17 else /* next该进程第一次被执行 */
18 {
19 next->state = 0;
20 my_current_task = next;
21 printk(KERN_NOTICE ">>>switch %d to %d<<<
",prev->pid,next->pid);
22 /* switch to new process */
23 asm volatile(
24 "pushl %%ebp
" /* 保存当前EBP到堆栈中 */
25 "movl %%esp,%0
" /* 保存当前ESP到PCB */
26 "movl %2,%%esp
" /* 将next进程的栈顶地址到ESP寄存器中 */
27 "movl %2,%%ebp
" /* 将next进程的堆栈基地址到ESP寄存器中 */
28 "movl $1f,%1
" /* 保存当前进程的EIP寄存器值到PCB,这里$1f是指上面的标号1 */
29 "pushl %3
" /* 把即将执行的进程的代码入口地址入栈 */
30 "ret
" /* 出栈进程的代码入口地址到EIP寄存器*/
31 : "=m" (prev->thread.sp),"=m" (prev->thread.ip)
32 : "m" (next->thread.sp),"m" (next->thread.ip)
33 );
34 }
为了简便,假设系统只有两个进程,分别是进程0和进程1。进程0由内核启动时初始化执行,然后需要进程调度,开始执行进程1。下面从进程1被调度开始分析堆栈变化,因为进程1从来没有被执行过,此时执行else中的代码。堆栈和相关寄存器的变化过程如下图所示:
如果进程1执行的过程中发生了进程调度,进程0重新被调度执行了,这个时候应该执行if中的代码,if中的内嵌汇编代码执行过程中堆栈的变化分析如下图所示:
然后这里有一个问题:$1f为前方的标号1,if中有标号1,else中没有标号1。这是因为else中是进程第一次被执行的代码,只用将其存入prev->thread.ip,并不需要使用$lf来获取程序入口地址,但是if中的代码代表的是进程再次被重新调度执行,prev->thread.ip变成了next->thread.ip,此时进入了if代码块中会将next->thread.ip压栈,并由ret出栈到EIP寄存器中,这时就需要使用上次被调度出去时保存的$1f。
4 总结
操作系统首先初始化内核相关的进程,然后开始循环运行这些进程,当进程间进行切换时,利用内核堆栈所保存的每个进程的sp,ip即所对应的%esp,%eip寄存器中的值,对当前的进程的sp,ip即对应%esp,%eip寄存器的值进行保存(中断上下文),并用下一个进程的sp,ip的值赋值给%esp,%eip寄存器(进程间切换)。