《ORANGE’S:一个操作系统的实现》读书笔记(十五)进程(三)

我们现在完成了ring0到ring1的跳转,并且我们此时的进程不仅是在运行而已,它可以随时被中断,可以在中断处理程序完成之后被恢复。进程此时已经有了两种状态:进行和睡眠。接下来我们就要处理多个进程了,思路也不复杂,只需要让其中一个进程处在运行态,其余进程处在睡眠态就可以了。那么接下来,就开始记录多线程。

多进程

添加一个进程体

别的不用说,添加一个进程体是少不了的,让我们先把这个最简单的工作做完,在main.c中进程A的代码的下面添加进程B。

代码 kernel/main.c,添加进程B。

void TestB()
{
    int i = 0x1000;
    while(1) {
        disp_str("B");
        disp_int(i++);
        disp_str(".");
        delay(1);
    }
}

在这里,除了打印的字母换成B之外,i的初始值被设置为了0x1000,为的就是在将来程序运行时能清晰地分辨两个进程。进程体其余的地方跟进程A完全一样。

相关的变量和宏

让我们来回忆一下准备第一个进程时还做了哪些工作。我们前面提到,进程不外乎4个要素:进程表、进程体、GDT、TSS。

进程体我们已经有了,下一个要关注的就是进程表如何初始化。让我们再一次来到kernel_main()函数中看一下进程A是如何初始化的。看起来很简单,将其中的几个关键成员赋值就可以了。我们当然可以将这份代码复制一份,将其中涉及进程A的内容统统改成与进程B相关的代码。可是显然那样做有些不好,因为我们不可能每增加一个进程就复制一份代码,最好能够让代码在某种程度上实现自动化,让增加一个进程变得简单。

书上这里采用了拿来主义,Minix中定义了一个数组叫做tasktab。这个数组的每一项定义好一个任务的开始地址、堆栈等,在初始化的时候,只要用一个for循环依次读取每一项,然后填充到相应的进程表项中就可以了。

我们首先在proc.h中声明一个数组类型。

代码 include/proc.h。

typedef struct s_task {
    task_f initial_eip;
    int stacksize;
    char name[32];
}TASK;

其中,task_f是这样定义的(在type.h中):

typedef void (*task_f) ();

一个进程只要有一个进程体和堆栈就可以运行了,所以,这个数组只要有前两个成员就可以了。这里,还定义了name,以便给每个进程起一个名字。

好了,下面我们在global.c中添加这样一个定义。

代码 kernel/global.c,task_table。

PUBLIC TASK task_table[NR_TASKS] = {{TestA, STACK_SIZE_TESTA, "TestA"},
                                    {TestB, STACK_SIZE_TESTB, "TestB"}};

别忘了同时在global.h中加入这么一行:

extern TASK         task_table[];

此外你一定想起来一件事,就是我们当初就考虑到了以后得扩充,虽然只有一个进程,我们还是安排了一个进程表数组proc_table[NR_TASKS],只是NR_TASKS的值为1罢了。显然,进程表里有几项,task_table也应该有几项。在这里,我们已经有两个进程了,所以先把NR_TASKS的修改为2。代码中的STACK_SIZE_TESTB还没有定义,我们现在来将它们定义一下。

代码 include/proc.h。

/* 最大允许进程数 */
#define NR_TASKS 2

/* stacks of tasks */
#define STACK_SIZE_TESTA 0x8000
#define STACK_SIZE_TESTB 0x8000

#define STACK_SIZE_TOTAL (STACK_SIZE_TESTA + STACK_SIZE_TESTB)

最后,在proto.h中加入TestB的函数声明:

void TestB();

好了,围绕task_table,与新添加进程相关的定义已经完成,下面我们就开始做进程表的初始化工作。

进程表初始化代码扩充

现在可以用for循环来做进程表的初始化工作了。

代码 kernel/main.c,初始化进程表。

PUBLIC int kernel_main()
{
    disp_str("-----\"kernel_main\" begins-----\n");

    TASK* p_task = task_table;
    PROCESS* p_proc = proc_table;
    char* p_task_stack = task_stack + STACK_SIZE_TOTAL;
    u16 selector_ldt = SELECTOR_LDT_FIRST;
    int i;
    for(i=0;i<NR_TASKS;i++){
        strcpy(p_proc->p_name, p_task->name); /* name of the process */
        p_proc->pid  =  i; /* pid */

        p_proc->ldt_sel = selector_ldt;

        memcpy(&p_proc->ldts[0], &gdt[SELECTOR_KERNEL_CS >> 3], sizeof(DESCRIPTOR));
        p_proc->ldts[0].attr1 = DA_C | PRIVILEGE_TASK << 5;
        memcpy(&p_proc->ldts[1], &gdt[SELECTOR_KERNEL_DS >> 3], sizeof(DESCRIPTOR));
        p_proc->ldts[1].attr1 = DA_DRW | PRIVILEGE_TASK << 5;
        p_proc->regs.cs = ((8 * 0) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
        p_proc->regs.ds = ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
        p_proc->regs.es = ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
        p_proc->regs.fs = ((8 * 1) & SA_RPL_MASK & SA_TI_MASK) | SA_TIL | RPL_TASK;
        p_proc->regs.ss = ((8 * 1) & 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)p_task->initial_eip;
        p_proc->regs.esp = (u32)p_task_stack;
        p_proc->regs.eflags = 0x1202; /* IF=1, IOPL=1 */

        p_task_stack -= p_task->stacksize;
        p_proc++;
        p_task++;
        selector_ldt += 1 << 3;
    }

    k_reenter = -1;

    p_proc_ready = proc_table;
    restart();

    while(1) {}
}

在这个代码中,进程之间的区别真的不大。每一次循环的不同在于,从TASK结构中读取不同的任务入口地址、堆栈栈顶和进程名,然后赋给相应的进程表项。需要注意的地方有两点。

  • 由于堆栈是从高地址向低地址生长的,所以在给每一个进程分配堆栈空间的时候也是从高地址往低地址进行。
  • 在这里,我们为每一个进程都在GDT中分配一个描述符用来对应进程的LDT。在protect.h中可以看到,SELECTOR_LDT_FIRST是GDT中被定义的最后一个描述符,但是正如它的名字所表示的,它仅仅是“第一个”和“唯一一个”被明白指出来的而已。实际上,我们在task_table中定义了几个任务,通过上文的for循环中的代码,GDT就会有几个描述符被初始化,它们列在SELECTOR_LDT_FIRST之后。

LDT

每一个进程都会在GDT中对应一个LDT描述符。于是在for循环中,我们将每个进程表项中的成员p_proc->ldt_sel赋值。可是,选择子仅仅是解决了where问题,通过它,我们能在GDT中找到相应的描述符,但描述符的具体内容是什么,即what的问题还没有解决。

开始只有一个进程时,我们是在init_prot()这个函数中通过一个语句解决了what的问题。现在,我们同样需要把它变成一个循环。

代码 kernel/protect.c,初始化LDT。

    /* 填充 GDT 中进程的 LDT 的描述符 */
    int i;
    PROCESS* p_proc = proc_table;
    u16 selector_ldt = INDEX_LDT_FIRST << 3;
    for (i = 0; i < NR_TASKS; i++) {
        init_descriptor(&gdt[selector_ldt >> 3],
                        vir2phys(seg2phys(SELECTOR_KERNEL_DS), proc_table[i].ldts),
                        LDT_SIZE * sizeof(DESCRIPTOR) - 1,
                        DA_LDT);
        p_proc++;
        selector_ldt += 1 << 3;
    }

另外,每个进程都有自己的LDT,所以当进程切换时需要重新加载ldtr。

修改中断处理程序

现在我们其实可以make一下,然后运行,看一下效果。发现和原先的效果一模一样,没有变化。这是因为每次发生时钟中断的时候,被恢复的进程还是原来的进程A,我们还没有编写进程切换的代码。

时钟中断处理程序位于kernel.asm中,除了保存和恢复进程信息,我们只做了一件简单的事,就是在屏幕上打印了一个字符“^”。显然,进程切换的代码就应该添加在这个位置才对。

回忆一下,一个进程如何由“睡眠”状态变成“运行”状态?无非是将esp指向进程表项的开始处,然后在执行lldt之后经历一系列pop指令恢复各个寄存器的值。一切信息都包含在进程表中,所以,要想恢复不同的进程,只需要将esp指向不同的进程表就可以了。

在离开内核栈的时候,有一个语句是为esp赋值的:

mov esp, [p_proc_ready]

全局变量p_proc_ready是指向进程表结构的指针,我们只需要在这一句执行之前把它赋予不同的值就可以了。

可以想到,进程切换是一个有点复杂的过程,因为涉及到进程调度等内容。一方面,这涉及算法等一些复杂内容,另一方面,它应该是与硬件无关的,所以我们用C语言来编写这个模块。这块内容既是时钟中断的一部分,又关乎进程调度。所以我们创建一个新文件clock.c。目前clock.c里面只有一个函数。

代码 kernel/clock.c。

PUBLIC void clock_handler(int irq)
{
    disp_str("#");
}

函数只有一个语句,就是打印一个字符“#”。下面我们在时钟中断例程中调用这个函数。

代码 kernel/kernel.asm。

hwint00:                ; Interrupt routine for irq 0 (the clock).
    sub esp, 4
    pushad              ; `.
    push ds             ; |
    push es             ; | 保存原寄存器的值
    push fs             ; |
    push gs             ; /
    mov dx, ss
    mov ds, dx
    mov es, dx

    inc byte [gs:0]         ; 改变屏幕第 0 行,第 0 列的字符

    mov al, EOI             ; `. reenable
    out INT_M_CTL, al       ; / master 8259

    inc dword [k_reenter]
    cmp dword [k_reenter], 0
    jne .re_enter

    mov esp, StackTop       ; 切到内核栈

    sti
    push 0
    call clock_handler
    add esp, 4
    cli

    mov esp, [p_proc_ready] ; 离开内核栈
    lldt [esp + P_LDT_SEL]
    lea eax, [esp + P_STACKTOP]
    mov dword [tss + TSS3_S_SP0], eax

.re_enter:                  ; 如果(k_reenter != 0),会跳到这里
    dec dword [k_reenter]
    pop gs                  ; `.
    pop fs                  ; |
    pop es                  ; | 恢复原寄存器值
    pop ds                  ; |
    popad                   ; /
    add esp, 4
    iretd

由于新增了一个文件,所以不要忘记修改Makefile。make,运行,运行效果如下所示。图示打印字符“#”,说明我们刚刚所增加的代码已经正确运行了。

下面该切换进程了。

代码 kernel/clock.c。

PUBLIC void clock_handler(int irq)
{
    disp_str("#");
    p_proc_ready++;
    if (p_proc_ready >= proc_table + NR_TASKS) {
        p_proc_ready = proc_table;
    }
}

每一次我们让p_proc_ready指向进程表中的下一个表项,如果切换前已经到达进程表结尾则回到第一个表项。好了,现在进程切换的代码已经添加上了,我们再make,运行。可以看到“A”和“B”交替出现,还有各自不断增加的数字。这表明我们的第二个进程运行成功了,我们已经成功实现了多进程。

毫无疑问,这又是一个历史性的时刻。因为到目前为止,一个多进程的框架已经基本完成,在此基础上,你可以方便地添加任务,并且方便地设计调度算法对这些任务进行管理。从此以后,操作系统课本上的调度算法不再是空洞的理论,而变成了你手中可以随意指挥和试验的代码。

添加一个任务的步骤总结

现在我们已经有两个进程在运行了,一个到两个是质的飞跃,两个到三个就仅仅是量的积累了,就容易得多了。话虽如此,但是如果隔上一段时间,回头再来添加一个进程,没准还是会有忘记的地方。那么,我们就再来添加一个任务,并把添加一个任务的步骤进行总结。

首先添加一个进程体。

代码 kernel/main.c,TestC。

void TestC()
{
    int i = 0x2000;
    while(1) {
        disp_str("C");
        disp_int(i++);
        disp_str(".");
        delay(1);
    }
}

然后在task_table中添加一项进程。

代码 kernel/global.c。

PUBLIC TASK task_table[NR_TASKS] = {{TestA, STACK_SIZE_TESTA, "TestA"},
                                    {TestB, STACK_SIZE_TESTB, "TestB"},
                                    {TestC, STACK_SIZE_TESTC, "TestC"}};

然后是include/proc.h。

/* 最大允许进程数 */
#define NR_TASKS 3

/* stacks of tasks */
#define STACK_SIZE_TESTA 0x8000
#define STACK_SIZE_TESTB 0x8000
#define STACK_SIZE_TESTC 0x8000

#define STACK_SIZE_TOTAL (STACK_SIZE_TESTA + STACK_SIZE_TESTB + STACK_SIZE_TESTC)

在proto.h中添加函数声明:

void TestC();

make,运行,效果如下所示。

简单几步就完成了一个任务的添加,我们把添加任务的步骤做一下总结。增加一个任务需要的步骤:

  1. 在task_table中增加一项(global.c)。
  2. 让NR_TASKS加1(proc.h)。
  3. 定义任务栈(proc.h)。
  4. 修改STACK_SIZE_TOTAL(proc.h)。
  5. 添加新任务体的函数声明(proto.h)。

除了任务本身的代码和一些宏定义之外,原来的代码几乎不需要做任何改变,看来我们的代码自动化程度还是可以的。

公众号

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值