我们的目标

在本节中,我们将让多个任务并行运行在Skelix中,每一个任务都有自己的LDT,我们将实现抢占式多任务切换。

下载代码


TSS

首先让我们澄清一件事,在单处理器(单核)上同时运行多任务是不可能的,实际中,处理器只是在任务间切换非常快,像每秒100次,所以看起来像同时有多个进程在运行。

386处理器对多任务提供硬件支持,但是也可以通过软件方式实现任务切换,这是我以前写的一个例子,它在多个代码段中切换执行。我们都知道每个处理器中都只有一套寄存器,并且它们被所有的处理器共享,所以当一个执行中的进程停止并在它切换到其它进程前,有关于当前进程的信息,即寄存器和其它的一些信息都会被储存,因为其它进程会使用它们。这些信息被称为“上下文”,386处理器使用任务状态段来储存这些信息,它至少有104字节长,并有一个TSS描述符指向它。TSS描述符只能在GDT中出现,也就意味着用户任务不能通过它们自己的LDT来进行任务切换。当我们用硬件方式进行任务切换时,处理器把应储存的信息自动存储在TSS中。

TSS定义了任务的执行环境状态。看一下TSS结构,

TSS structure

表很长,但实际上没有看起来的那么吓人。那些标明“not used”的域被Intel保留。386处理器支持任务嵌套,也就是说当任务1切换到任务2后,任务2的“Back link”域被设置成任务1的选择子并且EFLAGS寄存器的NT(嵌套任务)被设置,因此当任务2返回时,处理器知道哪个任务应该获得控制权。我们知道386处理器支持4个不同的权限级,并且TSS允许在任务切换时每个权限级使用不同的堆栈,所以一个进程可以有4个不同的堆栈。在后面的教程中我们准备讨论一下寄存器CR3。以后我们会讨论LDT。在此时,不用关心I/O位映射和T位是什么东西。

像GDT中的其它描述符那样,TSS也需要GDT中有一个描述符指向它,这是格式,

TSS descriptor

一般的GDT描述符,

XGT

我们可以把它和一般的GDT描述符比较一下,我么可以发现D(数据或代码段)和X(未用)位被设为0,AVL用户可用。类型是010B,B(位41)是TSS描述符中的忙状态位,因为每个进程只能有一个TSS,一旦在执行状态TSS的B位被设置,表示这个TSS不能被使用了以防止再入。A(存取)被设置,但是即使A被设置的情况下,进程既不能读也不能写TSS。描述符中的其它域和普通的数据和代码段的意义相同,只需要记住一件事,TSS的描述符长度必须大于104字节,这是TSS的最小长度。另一件事,实际上大多数操作系统使用软件方式进行上下文切换,因为硬件方式只保存上面我们所提到的寄存器,不包含FPUMMXSSE之类。但是我们在Skelix中用不到它们所以我们可以安全的用TSS进行环境保存。

在本节中,TSS的DPL域被设为0,因此只有内核有权限进行任务切换。就像GDTRIDTR,TSS描述符也有它自己用于任务切换的寄存器TR,可以用指令LTR载入。指向GDT中TSS描述符的选择子不能被装入其它任何段寄存器,否则异常发生。

有好几种方法可以进行任务切换,我们准备使用一个长转跳到GDT表中TSS描述符的方法进行。所有上面提到的东西都是为了这个目的。如果你想用其它的办法,那么你最好自己到处翻代码吧,我相信关于这个话题的书是很少……很少……少……的。

任务切换时,处理器首先保存当前任务的上下文在当前TSS中,然后载入新任务的任务寄存器,通过新任务在GDT中描述符中的TSS内容载入寄存器,最后执行新任务。

看一下代码,这是我们要用到的TSS结构,

06/include/task.h

struct TSS_STRUCT {

        unsigned int back_link;

        unsigned int esp0, ss0;

        unsigned int esp1, ss1;

        unsigned int esp2, ss2;

        unsigned int cr3;

        unsigned int eip;

        unsigned int eflags;

        unsigned int eax,ecx,edx,ebx;

        unsigned int esp, ebp;

        unsigned int esi, edi;

        unsigned int es, cs, ss, ds, fs, gs;

        unsigned int ldt;

        unsigned int trace_bitmap;

};

它和我提到的TSS结构完全一样,但是既然处理器要用这个结构,那么它必须是104字节长并且没有填充,如果你使用IA-64结构或者其它架构或其它编译器,请自己查看文档。

对一个操作系统而言,这些信息是不够的,所以另一个结构TASK_STRUCT被用来包裹这个TSS结构。

#define TS_RUNNING      0

#define TS_RUNABLE      1

#define TS_STOPPED      2

 

struct TASK_STRUCT {

        struct TSS_STRUCT tss;

        unsigned long long tss_entry;

        unsigned long long ldt[2];

        unsigned long long ldt_entry;

        int state;

        int priority;

        struct TASK_STRUCT *next;

};

 

#define DEFAULT_LDT_CODE        0x00cffa000000ffffULL

#define DEFAULT_LDT_DATA        0x00cff2000000ffffULL

 

#define INITIAL_PRIO            200

这是我们所需的一切,这些TS_*定义了所有有效的进程状态。 TASK_STRUCT中的tss_entry定义了TSS描述符,一会儿我会解释它。两个*ldt*域也会一会儿解释。state存储了当前进程状态,即几个TS_*之一。priority定义了进程被执行的优先级,新任务会被给予一个初始优先级INITIAL_PRIO。所有Skelix管理的任务都被连接成链表,next指向链表中的下一个任务。

让我们看看例子,这个TASK_STRUCT为任务0准备,这是系统中的第一个任务,当内核完成初始化后,就成为了任务0。

static unsigned long TASK0_STACK[256] = {0xf};

这是这个任务用的CPL为0时的栈。没有那个0xF,我遇到了编译器的一个问题,如果仅仅像static unsigned long TASK0_STACK[256];这样定义,那么这个内存区域就消失了,必须得把它的第一个值设为非零。(沈峰解释说,如果没有初始值的话,TASK0_STACK[256]就是一个初始为零的静态类型,这种类型会被放置到.bss中,目标文件只会记录它的长度和地址之类的东西,而不保存它占用的空间。所以我们必须把它的一个值设为非零,来把它放到.data中去。)

struct TASK_STRUCT TASK0 = {

        /* tss */

        {       /* back_link */

                0,

                /* esp0                                    ss0 */

                (unsigned)&TASK0_STACK+sizeof TASK0_STACK, DATA_SEL, 

esp0指向“栈底”。下面出现的DATA_SELCODE_SEL定义在06/include/kernel.h中,它们是GDT中的数据段和代码段的选择子。

                /* esp1 ss1 esp2 ss2 */

                0, 0, 0, 0, 

                /* cr3 */

                0, 

                /* eip eflags */

                0, 0, 

                /* eax ecx edx ebx */

                0, 0, 0, 0,

                /* esp ebp */

                0, 0,

                /* esi edi */

                0, 0, 

                /* es          cs             ds */

                USER_DATA_SEL, USER_CODE_SEL, USER_DATA_SEL, 

                /* ss          fs             gs */

                USER_DATA_SEL, USER_DATA_SEL, USER_DATA_SEL, 

                /* ldt */

                0x20,

                /* trace_bitmap */

                0x00000000},

                /* tss_entry */

                0,

                /* idt[2] */

                {DEFAULT_LDT_CODE, DEFAULT_LDT_DATA},

                /* idt_entry */

                0,

                /* state */

                TS_RUNNING,

                /* priority */

                INITIAL_PRIO,

                /* next */

                0,

};

我们有了TSS,我们准备做一个TSS描述符,记住GDT中有两个保留的位置。

06/bootsect.s

gdt:   

        .quad    0x0000000000000000 # null descriptor

        .quad    0x00cf9a000000ffff # cs

        .quad    0x00cf92000000ffff # ds

        .quad    0x0000000000000000 # reserved for tss

        .quad    0x0000000000000000 # reserved for ldt

第四个项(0x3)为当前任务的TSS保留,所以定义了CURR_TASK_TSS = 3作为GDT中这个位置的索引。我们准备让当前任务使用这个位置,一旦它放弃控制,它将这个描述符存在它TASK_STRUCT中的tss_entry里。当新任务接过控制权后,它把它自己的在TASK_STRUCT中的TSS描述符存入这个位置,通过这种方式我们可以允许无限多的任务运行在系统中。因为GDT有长度限制,它总共可以存储8096个描述符,实际上Linux在很长时间里都限制任务数。我不明白为什么有这个限制,因为看起来取消掉这个限制不是很困难的事情。

unsigned long long 

set_tss(unsigned long long tss) {

        unsigned long long tss_entry = 0x0080890000000067ULL;

        tss_entry |= ((tss)<<16) & 0xffffff0000ULL;

        tss_entry |= ((tss)<<32) & 0xff00000000000000ULL;

        return gdt[CURR_TASK_TSS] = tss_entry;

}

set_tss生成TSS描述符,并把它放入GDT中,我们可以看到这个描述符的DPL是0,所以只有内核可以使用这个描述符。

unsigned long long

get_tss(void) {

        return gdt[CURR_TASK_TSS];

}


LDT

我们用GDT有一段时间了,LDT就应该很简单了。LDT很像GDT,只是是局部的,每个任务都可以有它自己的LDT,我们要让Skelix中的每一个任务都在LDT中有两个描述符,第一个是代码段描述符,第二个是数据和堆栈段描述符。现在让我们复习一下选择子的格式,

selector format

我们要让所有的任务运行在权限等级3,也就是说RPL=11b,并且TI=1指出这个选择子指向LDT。与GDT不同,LDT中的第一个描述符可以被用户使用,因此指向代码段的选择子是0x7,指向数据和堆栈段的选择子是0xF。

TSS中的LDT域是一个指向GDT中描述符的选择子,这个描述符描述了这个LDT。我知道它听起来很令人困惑,所以让我把它说明白点,首先我们让每一个任务有一个LDT,并且这个LDT存在内存中的某个位置(现在我们不关心虚拟内存的问题),并且我们需要一个描述符去描述这个包含着LDT的内存区域,这个描述符会被放到GDT中,指向它的选择子会被放到这个任务TSS的LDT域中。

tss ldt gdt

GDT中的第三个描述符是给当前任务的TSS,第四个非常清楚,是当前任务的LDT。因此它们的选择子分别是0x18和0x20。像处理TSS的函数那样,我们有,

06/task.c

unsigned long long 

set_ldt(unsigned long long ldt) {

        unsigned long long ldt_entry = 0x008082000000000fULL;

        ldt_entry |= ((ldt)<<16) & 0xffffff0000ULL;

        ldt_entry |= ((ldt)<<32) & 0xff00000000000000ULL;

        return gdt[CURR_TASK_LDT] = ldt_entry;

}

很像GDT项,除了DPL不是0而是3。

 

unsigned long long

get_tss(void) {

        return gdt[CURR_TASK_TSS];

}

在此刻我们让所有的任务有同样的LDT内容,意味着它们共享内存空间,这不是个好主意,但我会通过虚拟内存给它们不同的内存空间,会在后面教程里介绍。

06/include/task.h

#define DEFAULT_LDT_CODE        0x00cffa000000ffffULL

#define DEFAULT_LDT_DATA        0x00cff2000000ffffULL

这些是任务LDT中的LDT描述符,它们几乎和GDT一样,除了DPL是3。

我上面提到过,所有的任务都通过单链表连到一起,这里有两个重要的指针,一个是TASK0中的next,它是链表头,另一个current指针指向当前运行的任务。


创建和调度任务

在开始任务前,我们定义,

06/task.c

struct TASK_STRUCT *current = &TASK0;

接下来我们看一下任务是怎么生成的,

06/init.c

static void

new_task(struct TASK_STRUCT *task, unsigned int eip

                unsigned int stack0, unsigned int stack3) {

new_task接受四个参数,第一个是新任务的TASK_STRUCT结构,第二个是程序的入口点,TSS中的eip将会被赋予此值,所以处理器才知道哪里去执行程序代码,esp0esp3参数是为了设定权限级别0和3时的ESP,因为它们的选择子有固定值0x10和0xf,ss0和ss就没有被使用。

    memcpy(task, &TASK0, sizeof(struct TASK_STRUCT));

    task->tss.esp0 = stack0;

    task->tss.eip = eip;

    task->tss.eflags = 0x3202;

    task->tss.esp = stack3;

 

    task->priority = INITIAL_PRIO;

 

    task->state = TS_STOPPED;

TASK0被用来当做模板,我们仅需改变几个域就可以。在任务链接正确修改前,我们改变新任务状态到TS_STOPPED。否则,另一个new_task调用时会破坏链表。

    task->next = current->next;

    current->next = task;

    task->state = TS_RUNABLE;

}

将新任务加入链表中。

extern void task1_run(void);

extern void task2_run(void);

它们是新任务,一会儿介绍。

static long task1_stack0[1024] = {0xf, };

static long task1_stack3[1024] = {0xf, };

static long task2_stack0[1024] = {0xf, };

static long task2_stack3[1024] = {0xf, };

因为本节中还没有内存管理,所以我们仅仅建立几个数组作为任务堆栈。

void 

init(void) {

    char wheel[] = {'\\', '|', '/', '-'};

    int i = 0;

    struct TASK_STRUCT task1;

    struct TASK_STRUCT task2;

 

    idt_install();

    pic_install();

    kb_install();

    timer_install(1000);

    set_tss((unsigned long long)&TASK0.tss);

    set_ldt((unsigned long long)&TASK0.ldt);

载入TASK0的LDT和TSS描述符到GDT中。

    __asm__ ("ltrw    %%ax\n\t"::"a"(TSS_SEL));

    __asm__ ("lldt    %%ax\n\t"::"a"(LDT_SEL));

像GDT和LDT那样,也有两个为TSS准备的寄存器,TRLDTR,它们分别用ltrlldt指令装入。在操作任务之前,我们必须在TRLDTR中装入有效的值,否则你会得到一般保护错。

    sti();

    new_task(&task1,

            (unsigned int)task1_run, 

            (unsigned int)task1_stack0+sizeof task1_stack0,

            (unsigned int)task1_stack3+sizeof task1_stack3);

    new_task(&task2,

            (unsigned int)task2_run,

            (unsigned int)task2_stack0+sizeof task2_stack0,

            (unsigned int)task2_stack3+sizeof task2_stack3);

建立两个新任务,注意,建立之前,中断已经被打开了。

    __asm__ ("movl %%esp,%%eax\n\t" \

             "pushl %%ecx\n\t" \

             "pushl %%eax\n\t" \

             "pushfl\n\t" \

             "pushl %%ebx\n\t" \

             "pushl $1f\n\t" \

             "iret\n" \

             "1:\tmovw %%cx,%%ds\n\t" \

             "movw %%cx,%%es\n\t" \

             "movw %%cx,%%fs\n\t" \

             "movw %%cx,%%gs" \

             ::"b"(USER_CODE_SEL),"c"(USER_DATA_SEL));

现在内核成了任务TASK0,使用一个长返回,它从TASK0中载入了正确的EIPCSSSESP的值,栈看起来像这样,

+--------------------+

| LDT stack selector |

+--------------------+

|        ESP         |

+--------------------+

|       EFLAGS       |

+--------------------+

| LDT code selector  |

+--------------------+

|        EIP         |

+--------------------+

接着从LDT中载入数据段选择子。

    for (;;) {

        __asm__ ("movb    %%al,    0xb8000+160*24"::"a"(wheel[i]));

        if (i == sizeof wheel)

            i = 0;

        else

            ++i;

    }

}

现在所有的任务都在权限级3运行,所以其它有更高权限级的部分已经无法访问了,也就是保护的概念。但是在Skelix中,我不准备把内存空间完全分离,内存保护会通过虚拟内存的内存映射完成。在下面的教程中,用户任务可以通过系统调用访问系统函数。

现在我们有了三个任务,那么哪一部分会进行任务切换?像你想的那样,我们已经让计时器定期中断,所以这是当然的选择。我们必须像这样改变计时器中断的代码,

06/timer.c

void do_timer(void) {

        struct TASK_STRUCT *v = &TASK0;

        int x, y;

        ++timer_ticks;

        get_cursor(&x, &y);

        set_cursor(71, 24);

        kprintf(KPL_DUMP, "%x", timer_ticks);

        set_cursor(x, y);

        outb(0x20, 0x20);

        cli();

        for (; v; v=v->next) {

遍历任务列表,改变所有任务的优先级。

                if (v->state == TS_RUNNING) {

                        if ((v->priority+=30) <= 0)

                                v->priority = 0xffffffff;

                } else

                        v->priority -= 10;

        }

所有任务的优先级都会在时钟中断期间被改变,当前运行的任务有更高数值的优先级,也就是更低的优先级。等待的任务获得更高的优先级。

        if (! (timer_ticks%1))

                scheduler();

在两个时钟滴答时重新调度所有任务。

        sti();

}

看一下scheduler

void scheduler(void) {

        struct TASK_STRUCT *v = &TASK0, *tmp = 0;

        int cp = current->priority;

 

        for (; v; v = v->next) {

                if ((v->state==TS_RUNABLE) && (cp>v->priority)) {

                        tmp = v;

                        cp = v->priority;

                }

        }

遍历任务链表,执行拥有最高优先级的任务,也就是优先级数值最低的一个。

        if (tmp && (tmp!=current)) {

                current->tss_entry = get_tss();

                current->ldt_entry = get_ldt();

我们把GDT中TSS和LDT描述符的值存入TASK_STRUCT,因为描述符中的一些状态字可能被处理器改变了。

                tmp->tss_entry = set_tss((unsigned long long)((unsigned int)&tmp->tss));

                tmp->ldt_entry = set_ldt((unsigned long long)((unsigned int)&tmp->ldt));

                current->state = TS_RUNABLE;

                tmp->state = TS_RUNNING;

                current = tmp;

将要运行任务的TSS和LDT存入GDT,并改变它们的state

                __asm__ __volatile__("ljmp      $" TSS_SEL_STR ",       $0\n\t");

        }

}

Skelix使用长转跳指令来执行任务切换,段部分是TSS选择子,偏移部分被处理器忽略。TSS_SEL_STR被定义为"$0x18",必须包含引号,C才能把字符串连在一起。


A & B

最后,我们看一下task1_runtask2_run的代码,它们的入口由汇编写成而不是C,因为我现在不想处理C函数的栈变化。这两个任务其实调用了另外的C函数去做实际工作。

task1_run:

        call    do_task1

        jmp        task1_run

task2_run:

        call    do_task2

        jmp        task2_run

首先,让我们检查一下是否任务在权限3工作正常,我们调用kprintf来打印任务当前使用的CS,然后让它们原地跳,否则它们的输出不会停止,屏幕上翻太快什么也看不到。

06/init.c

void

do_task1(void) {

    unsigned int cs;

    __asm__ ("movl    %%cs,    %%eax":"=a"(cs));

    kprintf(KPL_DUMP, "%x", cs);

    for (;;)

        ;

}

 

void

do_task2(void) {

    unsigned int cs;

    __asm__ ("movl    %%cs,    %%eax":"=a"(cs));

    kprintf(KPL_PANIC, "%x", cs);

    for (;;)

        ;

}

不要忘记在Makefile中的KERNEL_OBJS加入新的模块,

06/Makefile

KERNEL_OBJS= load.o init.o isr.o timer.o libcc.o scr.o kb.o task.o kprintf.o exceptions.o

making process of tutorial06 01 first idt result

我们可以看到,首先我们使用LDT,并且CS是0x7,另一件事是kprintf使用全局缓冲,所以实际上所有的任务使用同一个缓冲区,因此当一个任务开始输出另一个任务尚未结束时,两个任务的输出可能会混在一起。

让我们看点好玩的,我们让两个任务输出不同的字符。

06/init.c

void

do_task1(void) {

    print_c('A', BLUE, WHITE);

}

 

void

do_task2(void) {

    print_c('B', GRAY, BROWN);

}

making process of tutorial06 02 A&B


你可以自由使用我的代码,如有疑问请联系我