进程是操作系统中最重要的概念之一,实际上,我们的工作成果在实现进程之前是不能被称作“操作系统”的。进程是一个比较复杂的概念,即便是最简单的雏形,仍然需要考虑很多的因素。接下来就开始记录关于进程方面的内容。
进程介绍
我们不妨把系统中运行的若干进程想像成一个人在一天内要做的若干样工作:总体来看,每样工作相对独立,并可以产生某种结果;从细节上看,每样工作都具有自己的方法、工具和需要的资源;从时间上看,每一个时刻只能有一项工作正在处理中(所谓一心不能二用),各项工作可以轮换来做,这对于最终结果没有影响。
进程与此是类似的,从宏观来看,它有自己的目标,或者说功能,同时又能受控于进程调度模块(类似于工作受控于人);从微观来看,它可以利用系统的资源,有自己的代码(类似于做事的方法)和数据,同时拥有自己的堆栈(数据和堆栈类似于做事需要的资源和工具);进程需要被调度,就好比一个人轮换着做不同的工作。进程示意如下图所示。
我们还是遵循过去的原则,先是形成一个最简陋的进程,然后模仿它再写一个,变成两个。我们试着让它们同时运行,并让我们的系统试着对它们进行调度,当然,使用的是最简单的调度算法。最后,再试着扩展进程的功能。
形成进程的必要考虑
你可能会想,进程不就是一块或大或小的代码吗,应该很简单吧,随便写几句,想要执行它的时候跳转过去不就行了吗。可是我要提醒你,我们将面对一个无法避免的麻烦,那就是进行调度。将来我们会有很多个进程,它们看上去就好像在同时运行,但是我们知道,CPU只有一个,我们只考虑单CPU系统,实际上哪怕我们有多个CPU,我们也不能增加一个进程就增加一个CPU。也就是说,CPU的个数通常总是小于进程的个数,于是在同一时刻,总是有“正在运行的”和“正在休息的”进程。所以,对于“正在休息的”进程,我们需要让它在重新醒来时记住自己挂起之前的状态,以便让原来的任务继续执行下去。
所以,我们需要一个数据结构记录一个进程的状态,在进程要挂起的时候,进程信息就被写入这个数据结构,等到进程重新启动的时候,这个信息重新被读出来。
事情其实还要更加复杂些,因为在很多情况下,进程和进程调度是运行在不同的层级上的。这里,本着简单的原则,我们让所有任务运行在ring1,而让进程切换运行在ring0。不过,进程自己是不知道什么时候被挂起,什么时候又被启动的,诱发进程切换的原因不只一种,比较典型的情况是发生了时钟中断。当时钟中断发生时,中断处理程序会将控制权交给进程调度模块。这时,如果系统认为应该进行进程切换,进程切换就发生了,当前进程的状态会被保存起来,队列中的下一个进程将被恢复执行。下图表示了单CPU系统中进程切换的情况,黑色条表示进程处在运行态,白色条表示进程处在休息态。在同一时刻,只能有一个进程处在运行态。进程切换的操作者是操作系统的进程调度模块。这里要说明的一点是,并非在每一次时钟中断时都一定会发生进程切换。
最简单的进程
好了,我们来想像一下进程切换时的情形。一个进程正在运行着,这时时钟中断发生了,特权级从ring1跳到ring0,开始执行时钟中断处理程序,中断处理程序这时调用进程调度模块,指定下一个应该运行的进程,当中断处理程序结束时,下一个进程准备就绪并开始运行,特权级又从ring0跳回ring1,我们把这个过程按照时间顺序整理如下:
- 进程A运行中。
- 时钟中断发生,ring1->ring0,时钟中断处理程序启动。
- 进程调度,下一个应运行的进程(假设为进程B)被指定。
- 进程B被恢复,ring0->ring1。
- 进程B运行中。
要想实现这些功能,我们必须完成的应该有以下几项:
- 时钟中断处理程序
- 进程调度模块
- 两个进程
我们来分析一下,以进程A到进程B切换为例,其中有哪些关键技术需要解决。然后用代码分别实现这几个部分。
进程的哪些状态需要被保存
只有可能被改变的才有保存的必要。我们的进程要运行,不外乎CPU和内存在相互协作,而不同进程的内存互不干涉(我们考虑最简单的情况,假设内存足够大),但是我们提过,CPU只有一个,不同进程共用一个CPU的一套寄存器。所以,我们要把寄存器的值统统保存起来,准备进程被恢复执行时使用。
进程的状态需要何时以及怎样被保存
为了保证进程状态完整,不被破坏,我们当然希望在进程刚刚被挂起时保存所有寄存器的值。保存寄存器我们已经很拿手了,push就可以了。不过,Intel想得更周到,不但有push,还有pushad,一条指令就可以保存许多寄存器值。而这些代码,我们应该把它写在时钟中断例程的最顶端,以便中断发生时马上被执行。
如何恢复进程B的状态
不用说,保存是为了恢复,既然保存用的是push,那么恢复一定是用pop了。等所有寄存器的值都被恢复,执行指令 iretd,就回到了进程B。
进程表的引入
进程的状态无疑是非常重要的,它关系到每一次进程挂起和恢复。可以预见,我们今后将多次提到它,对于这样重要的数据结构,总要有个名字,它的名字就是“进程表”(有些书中称之为进程控制块,也即PCB)。
进程表相当于进程的提纲,通过进程表,我们可以非常方便地进行进程管理。从代码编写这个角度来看,除中断处理的部分内容我们不得不使用汇编之外,我们还是要用C来编写大部分进程管理的内容。如果把进程表定义成一个结构体的话,对它的操作将会是非常方便的。毫无疑问,我们会有很多个进程,所以我们会有很多个进程表,形式一个进程表数组。进程表数组如下图所示。
进程表是用来描述进程的,所以它必须独立于进程之外。所以,当我们把寄存器值压到进程表内的时候,已经处在进程管理模块中了。
进程栈和内核栈
当寄存器的值已经被保存到进程表内,进程调度模块就开始执行了。但这时有一个很重要的问题,就是esp现在指向何处。
毫无疑问,我们在进程调度模块中会用到堆栈,而寄存器压到进程表之后,esp是指向进程表某个位置的。这就有问题了,如果接下来进行任何的堆栈操作,都会破坏进程表的值,从而在下一次进程恢复时产生严重的错误。为解决这个问题,避免错误的出现,一定要记得将esp指向专门的内核栈区域。这样,在短短的进程切换过程中,esp的位置就出现在3个不同的区域。
其中:
- 进程栈——进程运行时自身的堆栈。
- 进程表——存储进程状态信息的数据结构。
- 内核栈——进程调度模块运行时使用的堆栈。
在具体编写代码的过程中,一定要清楚当前使用的是哪个堆栈,避免破坏不应破坏的数据。
特权级变换:ring1->ring0
在我们以前的代码中,还没有使用过处ring0之外的其它特权级。对于有特权级变换的转移,如果由外层向内层转移时,需要从TSS中取出内层ss和esp作为目标代码的ss和esp。所以,我们必须事先准备好TSS(Task-State Stack),它是一个数据结构,里面包含多个字段,32位TSS如下图所示。由于每个进程相对独立,我们把涉及到的描述符放在局部描述符表LDT中,所以,我们还需要为每个进程准备LDT。
特权级变换:第一步——ring0->ring1
在我们刚才的分析过程中,我们假设的初始状态是“进程A运行中”。可是我们知道,到目前为止我们的代码完全运行在ring0。所以,可以预见,当我们准备开始第一个进程时,我们面临一个从ring0到ring1的转移,并启动A。这跟我们从进程B恢复的情形很相似,所以我们完全可以在准备就绪之后跳转到中断处理程序的后半部分,“假装”发生了一次时钟中断来启动进程A,利用iretd来实现ring0到ring1的转移,一旦转移成功,便可以认为已经在一个进程中运行了。下面就开始记录这一部分。
时钟中断处理程序
先来做最简单的。完善的时钟中断处理未必会很简单,但是前面说过,我们只打算实现ring0到ring1的转移,做到这一个用一个iretd指令就够了。此时并不需要关于进程调度的任何内容,所以时钟中断处理程序在这一步并不重要,我们完全可以做的简单。
ALIGN 16
hwint00: ; Interrupt routine for irq 0 (the clock).
iretd
在这段中断例程中什么也不做,直接返回。
化整为零:进程表、进程体、GDT、TSS
既然在进程开始之前要用到进程表中各项的值,我们首先要将这些值进行初始化。一个进程开始之前,只要指定好各段寄存器、eip、esp以及eflags,它就可以正常运行,至于其它寄存器是用不到的,所以我们得出这样的必须初始化的寄存器列表:cs、ds、es、fs、gs、ss、esp、eip、eflags。
值得注意的是,这里的cs、ds等段寄存器对应的将是LDT中而不再是GDT中的描述符。所以,我们的另一个任务是初始化局部描述符表。可以把它放置在进程表中,从逻辑上看,由于LDT是进程的一部分,所以这样安排也是合理的。同时,我们还必须在GDT中增加相应的描述符,并在合适的时间将相应的选择子加载给ldtr。
另外,由于我们用到了任务状态段,所以我们还必须初始化一个TSS,并且在TSS中添加一个描述符,对应的选择子将被加载给tr这个寄存器。其实,TSS中我们所有能够用到的只有两项,便是ring0的ss和esp,所以值需要初始化它们两个就够了。
在第一个进程开始之前,我们的准备工作已经做的差不多了,其核心便是一个进程表以及与之相关的TSS等内容。它们之间的对应关系如下图所示。
这个图看起来有点复杂,我们可以将它分为4个部分,那就是进程表、进程体、GDT和TSS。它们之间的关系大致分为三个部分:
- 进程表和GDT。进程表内的LDT Selector对应GDT中的一个描述符,而这个描述符所指向的内存空间存在于进程表内。
- 进程表和进程。进程表是进程的描述,进程运行过程中如果被中断,各个寄存器的值都会被保存进进程表中。但是,在我们的第一个进程开始之前,并不需要初始化太多内容,只需要知道进程的入口地址就足够了。另外,由于程序免不了用到堆栈,而堆栈是不受程序本身控制的,所以还需要事先指定esp。
- GDT和TSS。GDT中需要有一个描述符来对应TSS,需要实现初始化这个描述符。
好了,这4个部分的相互关系已经记录完了,那么现在,就让我们分别来做这4个部分的初始化工作。
第一步,准备一个小的进程体。
代码 kernel/main.c,TestA。
void TestA()
{
int i = 0;
while(1) {
disp_str("A");
disp_int(i++);
disp_str(".");
delay(1);
}
}
看到这个代码,可能有人会说了,这不是一个函数吗?这是个函数,而且是个功能极其简单的函数,但它已经满足作为一个进程体执行体的功能了。在它执行时会不停地循环,每循环一次就打印一个字符和一个字数,然后休息一会。
这段代码被放在main.c文件中,这是一个新文件。在我们编写的内核代码中,我们调用指令sti打开中断之后就用hlt指令让程序停止以等待中断的发生。现在,在这里,我们将最终让进程运行起来,而不能停在那里,所以程序需要继续执行下去。我们将hlt注释掉,并让程序跳转到kernel_main()这个函数中。这个函数在main.c中,目前除了显示一行字符外不做其它的事情。不过,由于在完成进程的编写之前,要让程序停在这里,所以我们用一个死循环作为它的结束。
代码 kernel/main.c,函数kernel_main。
PUBLIC int kernel_main()
{
disp_str("-----\"kernel_main\" begins-----\n");
while(1) {}
}
在kernel.asm的最后,我们跳转到kernel_main()中。
extern kernel_main
...
; sti
jmp kernel_main
进程A中的函数delay()作用是延迟,我们写得简单一点,使用循环进行延迟。
代码 lib/klib.c。
PUBLIC void delay(int time)
{
int i, j, k;
for (k = 0; k < time; k++) {
for (i = 0; i < 10; i++) {
for (j = 0; j < 1000000; j++) {}
}
}
}
运行的时候,如果发现两次打印之间的时间间隔不理想,可以调整这里的循环次数。
第二步,初始化进程表。
要初始化进程表,首先要有进程表结构的定义,我们把进程表结构体的定义放在 include/proc.h 中,这是一个新建的头文件。
代码 include/proc.h。
/* 进程表结构 */
typedef struct s_stackframe {
u32 gs; /* \ */
u32 fs; /* | */
u32 es; /* | */
u32 ds; /* | */
u32 edi; /* | */
u32 esi; /* | pushed by save() */
u32 ebp; /* | */
u32 kernel_esp; /* <- 'popad' will ignore it */
u32 ebx; /* | */
u32 edx; /* | */
u32 ecx; /* | */
u32 eax; /* / */
u32 retaddr; /* return addr for kernel.asm::save() */
u32 eip; /* \ */
u32 cs; /* | */
u32 eflags; /* | pushed by CPU during interrupt */
u32 esp; /* | */
u32 ss; /* / */
}STACK_FRAME;
/* 进程结构体 */
typedef struct s_proc {
STACK_FRAME regs; /* process registers saved in stack frame */
u16 ldt_sel; /* gdt selector giving ldt base and limit */
DESCRIPTOR ldts[LDT_SIZE]; /* local descriptors for code and data */
u32 pid; /* process id passed in from MM */
char p_name[16]; /* name of the process */
}PROCESS;
现在,结构体的定义有了,我们在global.c中声明一个进程表:
PUBLIC PROCESS proc_table[NR_TASKS];
其中,NR_TASKS定义了最大运行进程数,这里设置为1。
好了,进程表有了,现在我们就来初始化它。由于kernel_main()是最后一部分被执行的代码,那么初始化进程表的代码理应添加在这里。
代码 kernel/main.c,初始化进程表。
PROCESS* p_proc = proc_table;
p_proc->ldt_sel = SELECTOR_LDT_FIRST;
memcpy(&p_proc->ldts[0], &gdt[SELECTOR_KERNEL_CS>>3], sizeof(DESCRIPTOR));
p_proc->ldts[0].attr1 = DA_C | PRIVILEGE_TASK << 5; // change the DPL
memcpy(&p_proc->ldts[1], &gdt[SELECTOR_KERNEL_DS>>3], sizeof(DESCRIPTOR));
p_proc->ldts[1].attr1 = DA_DRW | PRIVILEGE_TASK << 5; // change the DPL
p_proc->regs.cs = (0 & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
p_proc->regs.ds = (8 & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
p_proc->regs.es = (8 & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
p_proc->regs.fs = (8 & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
p_proc->regs.ss = (8 & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
p_proc->regs.gs = (SELECTOR_KERNEL_GS & SA_RPL_MASK) | RPL_TASK;
p_proc->regs.eip = (u32)TestA;
p_proc->regs.esp = (u32) task_stack + STACK_SIZE_TOTAL;
p_proc->regs.eflags = 0x1202; // IF=1, IOPL=1, bit 2 is always 1.
进程表需要初始化的主要有3个部分:寄存器、LDT Selector和LDT。要初始化的寄存器比较多,我们看到,cs指向LDT中第一个描述符,ds、es、fs、ss都设为指向LDT第二个描述符,gs仍然指向显存,只是RPL发生改变。接下来,eip指向TestA,这表明进程将从TestA的入口地址开始运行。另外,esp指向了单独的栈,栈的大小为STACK_SIZE_TOTAL。最后一行是设置eflags,0x1202恰好设置了IF位并把IOPL设为1.这样,进程就可以使用I/O指令,并且中断会在iretd执行时被打开。
代码中用到的宏大部分定义在protect.h中,代码如下。
/* GDT */
/* 描述符索引 */
#define INDEX_DUMMY 0 // ┓
#define INDEX_FLAT_C 1 // ┣ LOADER 里面已经确定了的.
#define INDEX_FLAT_RW 2 // ┃
#define INDEX_VIDEO 3 // ┛
#define INDEX_TSS 4
#define INDEX_LDT_FIRST 5
/* 选择子 */
#define SELECTOR_DUMMY 0 // ┓
#define SELECTOR_FLAT_C 0x08 // ┣ LOADER 里面已经确定了的.
#define SELECTOR_FLAT_RW 0x10 // ┃
#define SELECTOR_VIDEO (0x18+3) // ┛<-- RPL=3
#define SELECTOR_TSS 0X20 // TSS
#define SELECTOR_LDT_FIRST 0X28
#define SELECTOR_KERNEL_CS SELECTOR_FLAT_C
#define SELECTOR_KERNEL_DS SELECTOR_FLAT_RW
#define SELECTOR_KERNEL_GS SELECTOR_VIDEO
/* 每个任务有一个单独的 LDT,每个 LDT 中的描述符个数: */
#define LDT_SIZE 2
/* 选择子类型值说明 */
/* 其中,SA_:Selector Attribute */
#define SA_RPL_MASK 0xFFFC
#define SA_RPL0 0
#define SA_RPL1 1
#define SA_RPL2 2
#define SA_RPL3 3
#define SA_TI_MASK 0xFFFB
#define SA_TIG 0
#define SA_TIL 4
这里不但定义了SELECTOR_LDT_FIRST,而且定义了SELECTOR_TSS,我们还需要一个用来使用TSS的描述符。
这里,一定要记得LDT和GDT是联系在一起的,别忘了填充GDT中进程的LDT的描述符。
代码 kernel/protect.c,填充GDT中进程LDT的描述符
init_descriptor(&gdt[INDEX_LDT_FIRST],
vir2phys(seg2phys(SELECTOR_KERNEL_DS), proc_table[0].ldts),
LDT_SIZE * sizeof(DESCRIPTOR) - 1,
DA_LDT);
这段代码放在init_prot()中。init_descriptor和init_idt_desc有些类似。
代码 kernel/protect.c,函数init_descriptor。
PRIVATE void init_descriptor(DESCRIPTOR *p_desc, u32 base, u32 limit, u16 attribute)
{
p_desc->limit_low = limit & 0x0FFFF;
p_desc->base_low = base & 0x0FFFF;
p_desc->base_mid = (base >> 16) & 0x0FF;
p_desc->attr1 = attribute & 0xFF;
p_desc->limit_high_attr2 = ((limit>>16) & 0x0F) | (attribute>>8) & 0xF0;
p_desc->base_high = (base >> 24) & 0x0FF;
}
seg2phys的定义如下所示。
代码 kernel/protect.c,由段名求绝对地址。
PUBLIC u32 seg2phys(u16 seg)
{
DESCRIPTOR *p_dest = &gdt[seg >> 3];
return (p_dest->base_high<<24 | p_dest->base_mid<<16 | p_dest->base_low);
}
vir2phys是一个宏,定义在protect.h中。
代码 include/protect.h。
/* 线性地址 -> 物理地址 */
#define vir2phys(seg_base, vir) (u32)(((u32)seg_base) + (u32)(vir))
第三步,准备GDT和TSS。
现在,我们还没有初始化的只有TSS和它对应的描述符了。让我们来到init_prot(),填充TSS以及对应的描述符。
代码 kernel/protect.c,TSS相关。
/* 填充 GDT 中 TSS 这个描述符 */
memset(&tss, 0, sizeof(tss));
tss.ss0 = SELECTOR_KERNEL_DS;
init_descriptor(&gdt[INDEX_TSS],
vir2phys(seg2phys(SELECTOR_KERNEL_DS), &tss),
sizeof(tss) - 1,
DA_386TSS);
tss.iobase = sizeof(tss); /* 没有I/O许可位图 */
如今TSS已经准备好了,我们需要添加加载tr的代码。这更简单,只要在kernel.asm中添加几行就好了,代码如下所示。
xor eax, eax
mov ax, SELECTOR_TSS
ltr ax
代码 include/protect.h,TSS。
typedef struct s_tss {
u32 backlink;
u32 esp0; /* stack pointer to use during interrupt */
u32 ss0; /* " segment " " " " */
u32 esp1;
u32 ss1;
u32 esp2;
u32 ss2;
u32 cr3;
u32 eip;
u32 flags;
u32 eax;
u32 ecx;
u32 edx;
u32 ebx;
u32 esp;
u32 ebp;
u32 esi;
u32 edi;
u32 es;
u32 cs;
u32 ss;
u32 ds;
u32 fs;
u32 gs;
u32 ldt;
u16 trap;
u16 iobase; /* I/O位图基址大于或等于TSS段界限,就表示没有I/O许可位图 */
}TSS;
iretd
我们现在编写一个restart函数,这个函数将作为我们启动第一个进程的入口。由于我们只是想完成ring0到ring1的跳转,所以代码不用太复杂,具体代码如下所示。
代码 kernel/kernel.asm。
restart:
mov esp, [p_proc_ready]
lldt [esp + P_LDT_SEL]
lea eax, [esp + P_STACKTOP]
mov dword [tss + TSS3_S_SP0], eax
pop gs
pop fs
pop es
pop ds
popad
add esp, 4
iretd
其中,p_proc_ready是指向进程表结构的指针:
EXTERN PROCESS* p_proc_ready;
P_LDT_SEL、P_STACKTOP、TSS3_S_SP0和SELECTOR_TSS都定义在新建的文件sconst.inc中。一定要注意,这里的选择子必须与protect.h中的值保持一致。
代码 include/sconst.inc。
P_STACKTOP equ SSREG + 4
P_LDT_SEL equ P_STACKTOP
P_LDT equ P_LDT_SEL + 4
TSS3_S_SP0 equ 4
; 以下选择子值必须与 protect.h 中保持一致!!!
SELECTOR_FLAT_C equ 0x08 ; LOADER 里面已经确定了的.
SELECTOR_TSS equ 0x20 ; TSS
SELECTOR_KERNEL_CS equ SELECTOR_FLAT_C
由于进程的各个寄存器值现在已经在进程表里面保存好了,现在我们只要让esp指向栈顶,然后将各个值弹出就行了。最后一句iretd执行以后,eflags会被改变成pProc->regs.eflags的值。由于我们事先设置了IF位,所以进程开始运行的时候,中断也被打开了。
现在大部分工作已经完成,让我们回到kernel_main()。
代码 kernel/main.c。
p_proc_ready = proc_table;
restart();
最后的工作完成了,现在一切准备就绪。
进程启动
可以看到,仅仅为了一个跳转,我们做了如此多的工作,如今是检验成果的时候了。修改Makefile,执行make,运行,效果如下图所示。
可以看到屏幕上不断出现字符“A”和增加的数字,证明我们的进程运行了。这意味着我们实现了ring0到ring1的跳转,再进一步,这意味着我们的进程在运行,而这一切意味着我们现在编写的这个东西已经可以被称之为一个“操作系统了”。
第一个进程回顾
最后我们通过一张图来回顾一下进程启动的过程。
公众号