12 多任务

注:与上一篇类似,本节同样有参考《30天自制操作系统》,但不像上一节一样没有原创的东西(kmallockfree 的代码在29.3节中有)。

多任务,顾名思义,就是多个任务同时进行。在计算机中,这是一个非常重要的概念,否则这篇教程甚至写不出来(我需要一边打字一边写代码,显然需要两个一块开)。当然在现实生活中,不推荐使用多任务。

理论上讲,只有一个 CPU 的时候,是没有办法多个任务“同时”进行的——因为一次只能有一段代码在 CPU 上跑。但正像O(1)不是立即完成,而是“常量时间”,多任务也不是同时进行,而是“交替进行”,只是这种交替间隔时间极短。

在 intel x86 cpu 中,任务切换的核心是任务状态段(TSS),这一部分完全是 intel 硬件提供的。由于 TSS 是一个段,在实际使用时,需要把这个段注册到 GDT 中。

由于效率较低,在 Linux 等更为现代的操作系统中已经废弃了这种方法,改用一种上下文切换更加快速的 PCB 结构。但初学而言,还是用原生自带的比较好。

TSS 总共有 16 位、32 位和 64 位三种版本,我们来看看 32 位版的 TSS 长什么样:

代码 12-1 TSS32(include/mtask.h)

#ifndef _MTASK_H_
#define _MTASK_H_

#include "common.h"

typedef struct TSS32 {
    uint32_t backlink, esp0, ss0, esp1, ss1, esp2, ss2, cr3;
    uint32_t eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi;
    uint32_t es, cs, ss, ds, fs, gs;
    uint32_t ldtr, iomap;
} tss32_t;

#endif

TSS32 结构体的第二、三行,是任务切换中会随时更改的寄存器。任务切换发生时,会把当时寄存器的值存入 TSS。

第一、四行与任务切换相关,暂且按下不表。ldtr 这个名字,联想 gdtridtr,相信各位读者能够推测出它表示什么,第 22 节我们再回来重翻旧案。

由于 TSS32 的结构与硬件强相关,显然我们不能用它来代表一个任务,不然想给任务加点什么额外的属性,硬件就要有意见了。所以我们在外面套一层封装,构成 TASK 结构体:

代码 12-2 表示任务的结构体(include/mtask.h)

typedef struct TASK {
    uint32_t sel;
    int32_t flags;
    tss32_t tss;
} task_t;

目前,它只有两个属性:selflagssel 代表它对应的 TSS 的选择子,flags 代表它的标志,如是否使用过、是否在运行等。

接下来,我们来实现一个控制任务的结构体。由于注册的任务和实际的任务可能不一致,这需要两个 task_t 数组,由于较为复杂,打包为一个结构体:

代码 12-3 任务控制结构体(include/mtask.h)

#define MAX_TASKS 1000
#define TASK_GDT0 3

typedef struct TASKCTL {
    int running, now;
    task_t *tasks[MAX_TASKS];
    task_t tasks0[MAX_TASKS];
} taskctl_t;

runningnow 代表正在运行的任务数量和当前运行的任务编号,tasks 是实际运行任务的数组,tasks0 是任务注册时进入的数组。这样一来,我们只需要一个 taskctl_t,就可以引用到所有这些控制任务的变量了。

TASK_GDT0 表示从第多少号 GDT 开始分配给 TSS 使用。

任务控制的声明部分暂告一段落,接下来是实际的控制代码。系好安全带,我们开始吧。

首先,是初始化多任务环境的函数 task_init。首先它会初始化 taskctl,在执行完后,当前执行流将被当成一个任务来对待,这样做的目的是方便管理。

代码 12-4 初始化多任务环境(kernel/mtask.c)

#include "mtask.h"
#include "gdtidt.h"
#include "memory.h"
#include "isr.h"

extern void load_tr(int);
extern void farjmp(int, int);

taskctl_t *taskctl;

task_t *task_init()
{
    task_t *task;
    taskctl = (taskctl_t *) kmalloc(sizeof(taskctl_t));
    for (int i = 0; i < MAX_TASKS; i++) {
        taskctl->tasks0[i].flags = 0;
        taskctl->tasks0[i].sel = (TASK_GDT0 + i) * 8;
        gdt_set_gate(TASK_GDT0 + i, (int) &taskctl->tasks0[i].tss, 103, 0x89); // 硬性规定,0x89 代表 TSS,103 是因为 TSS 共 26 个 uint32_t 组成,总计 104 字节,因规程减1变为103
    }
    task = task_alloc();
    task->flags = 2;
    taskctl->running = 1;
    taskctl->now = 0;
    taskctl->tasks[0] = task;
    load_tr(task->sel); // 向CPU报告当前task->sel对应的任务为正在运行的任务
    return task;
}

这之中最难懂的大概就是倒数第三行的 load_tr 了吧。调用 task_init 的应该是 kernel_main,而 kernel_main 此时还没有任务形态,需要用 load_tr 来使得 CPU 认识到这是正在运行的任务。

代码 12-5 load_tr(lib/nasmfunc.asm)

[global load_tr]
load_tr:
    ltr [esp + 4]
    ret

在这之中,用到了 task_alloc,它是分配一个任务用的函数。先从 tasks0 中找到空项,然后进行一些初始化工作,最后返回一个崭新的任务。

代码 12-6 分配任务用 task_alloc(kernel/mtask.c)

task_t *task_alloc()
{
    task_t *task;
    for (int i = 0; i < MAX_TASKS; i++) {
        if (taskctl->tasks0[i].flags == 0) {
            task = &taskctl->tasks0[i];
            task->flags = 1;
            task->tss.eflags = 0x00000202; // 打开中断
            task->tss.eax = task->tss.ecx = task->tss.edx = task->tss.ebx = 0;
            task->tss.ebp = task->tss.esi = task->tss.edi = 0;
            task->tss.es = task->tss.ds = task->tss.fs = task->tss.gs = 0;
            task->tss.ldtr = 0;
            task->tss.iomap = 0x40000000;
            return task;
        }
    }
    return NULL;
}

eflags 的这个数值表示新任务默认开启中断。iomap 本意是让应用程序能够执行 in/out 指令所设置,但我们不需要让它执行这些指令,否则到时候执行个应用程序,键盘也不发信号了,时钟也不响了,就连电脑都重启了,所以把它设置成 0x40000000(事实上只要大于等于 103 就可以)表示:当作为应用程序时,不需要执行 in/out 指令。至于 CPU 怎么判断你是不是一个应用程序,第 22 节再说。

接下来是 task_run,使一个任务开始运行。实际上只是把这个任务加入了 tasks 数组而已。

代码 12-7 运行任务用 task_run(kernel/mtask.c)

void task_run(task_t *task)
{
    task->flags = 2;
    taskctl->tasks[taskctl->running] = task;
    taskctl->running++;
}

接下来是 task_switch,真正执行任务切换的部分。不过,我们好像还没有具体讲究竟是怎么任务切换的,我们现在来简单说一下。

其实非常简单,只需要用 farjmp 就可以了。当执行一个远跳转(就是我们之前用过的 jmp xxx:xxx)时,CPU 会检查对应的段是否是代码段,如果不是,就退而求其次检查是不是 TSS。如果是 TSS,就会先把当前任务的全部寄存器存到它的 TSS 里,然后自动读取 TSS 中的全部寄存器,这之中包括下一步执行哪里的 eip,从而恢复断点,继续执行。

代码 12-8 farjmp(lib/nasmfunc.asm)

[global farjmp]
farjmp:
    jmp far [esp + 4]
    ret

在实际运用中,应在 C 中如此调用:farjmp(eip, cs)eip 为下一步执行哪里的寄存器,如果跳的是 TSS,那就必须写 0;cs 为跳入的代码段选择子,在这里是 TSS。

为什么一定要这样呢?这和我们使用了 jmp far [addr] 来进行远跳转有关。你只需要知道,在这种情况下,[addr] 的位置必须写 EIP,[addr + 4] 的位置必须写 cs。

这样一来,task_switch 就十分简单了。

代码 12-9 任务切换(kernel/mtask.c)

void task_switch()
{
    if (taskctl->running >= 2) { // 显然,至少得有两个任务才能切换
        taskctl->now++; // 下一个任务
        if (taskctl->now == taskctl->running) { // 到结尾了
            taskctl->now = 0; // 转换为第一个
        }
        farjmp(0, taskctl->tasks[taskctl->now]->sel); // 跳入任务对应的 TSS
    }
}

结合注释应该不难理解……我是第几次说这句话了?

最后是 task_now,返回当前任务,后续会频繁用到。

代码 12-10 返回当前任务(kernel/mtask.c)

task_t *task_now()
{
    return taskctl->tasks[taskctl->now];
}

至此,我们已经基本完成了一个可用的任务处理框架。但是还有最后一个问题:谁来控制任务切换的进行呢?

还记得前面说的话吗?任务需要“交替运行”,也就是说,任务切换需要一段时间发生一次。我们发现,时钟中断刚好可以胜任!

因此,进入 timer.c,删除 tick 变量和所有对 tick 变量的操作,修改 timer_callback 如下:

代码 12-11 新版时钟中断回调(kernel/timer.c)

#include "mtask.h"

static void timer_callback(registers_t *regs)
{
    task_switch(); // 每出现一次时钟中断,切换一次任务
}

首先,进入到 main.c,添加一个创建内核任务的函数。由于代码量大且(将会)频繁用到,做一个小小的封装。

代码 12-12 创建内核任务(kernel/main.c)

#include "mtask.h"

task_t *create_kernel_task(void *entry)
{
    task_t *new_task;
    new_task = task_alloc();
    new_task->tss.esp = (uint32_t) kmalloc(64 * 1024) + 64 * 1024 - 4;
    new_task->tss.eip = (int) entry;
    new_task->tss.es = new_task->tss.ss = new_task->tss.ds = new_task->tss.fs = new_task->tss.gs = 2 * 8;
    new_task->tss.cs = 1 * 8;
    return new_task;
}

然后是新任务的主体 task_b_main,目前它还没啥大作用。

代码 12-13 新任务主体 task_b_main(kernel/main.c)

void task_b_main()
{
    while (1) monitor_put('B'); // 重复打印B
}

最后是新版 kernel_main

代码 12-14 最新内核主函数(kernel/main.c)

void kernel_main() // kernel.asm会跳转到这里
{
    monitor_clear(); // 先清屏
    init_gdtidt();
    init_memory();
    init_timer(100);
    asm("sti");

    task_t *task_a = task_init();
    task_t *task_b = create_kernel_task(task_b_main);
    task_run(task_b);

    // 此时kernel_main已经成为task_a的一部分
    while (1) monitor_put('A');
}

首先我们将时钟中断发生频率改为每0.1s发生一次,然后是创建、运行任务的代码,应该不难理解。

include/gdtidt.h 中加入一行声明:

代码 12-15 声明(include/gdtidt.h)

void gdt_set_gate(int32_t num, uint32_t base, uint32_t limit, uint16_t ar);

mtask.h 也要加入声明,全文如下:

代码 12-16 include/mtask.h

#ifndef _MTASK_H_
#define _MTASK_H_

#include "common.h"

typedef struct TSS32 {
    uint32_t backlink, esp0, ss0, esp1, ss1, esp2, ss2, cr3;
    uint32_t eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi;
    uint32_t es, cs, ss, ds, fs, gs;
    uint32_t ldtr, iomap;
} tss32_t;

typedef struct TASK {
    uint32_t sel;
    int32_t flags;
    tss32_t tss;
} task_t;

#define MAX_TASKS 1000
#define TASK_GDT0 3

typedef struct TASKCTL {
    int running, now;
    task_t *tasks[MAX_TASKS];
    task_t tasks0[MAX_TASKS];
} taskctl_t;

task_t *task_init();
task_t *task_alloc();
void task_run(task_t *task);
void task_switch();
task_t *task_now();

#endif

在所有声明添加完成后,编译运行,效果如图所示:

(图 12-1 运行成功)

我们看到了交错的 AB,这是个好现象。那么我们的多任务到此结束……

……那是不可能的。我们还有一些内容没有完成。我们还要实现 Linux 中 exitwaitpid 的功能。这一部分地基打好了,我们后面的应用程序才能更好地运行起来。

那么,我们再次开始。首先,exit 是有返回值的,我们需要在某一个地方存一下返回值。思来想去,最合适的地方还是在 TASK 结构体中,在 mtask.h 中添加这样一个定义:

代码 12-17 新声明(include/mtask.h)

typedef struct exit_retval {
    int pid, val;
} exit_retval_t;

typedef struct TASK {
    uint32_t sel;
    int32_t flags;
    exit_retval_t my_retval;
    tss32_t tss;
} task_t;

删去原本的 TASK 定义,替换为上面这一段。由于 exit 的返回值可以是任何一个东西,因此特意添加了一个 pid 变量,用来确认是否退出。当然这个变量可以换成随便一个东西,这里用 pid 只是一种用法。

那么,一个任务的 pid 是什么呢?pid 自然是一个 id,是一个任务的另一个身份证。一般而言,它是一个单独的数,表示它在一个任务数组或者什么地方的索引。

在这里,由于我们的全局数组是 taskctl->tasks0,因此,一个任务的 pid 就是它在 tasks0 中的索引。看起来从一个任务找 pid 是一个 O(n) 的操作,但是注意 task_init 中的这行代码:

        taskctl->tasks0[i].sel = (TASK_GDT0 + i) * 8;

倒推回去,就可以得到:一个任务对应的 pidtask->sel / 8 - TASK_GDT0。这是一个重要的结论,我们把它写成单独的函数 task_pid

代码 12-18 从任务找 pid(kernel/mtask.c)

int task_pid(task_t *task)
{
    return task->sel / 8 - TASK_GDT0;
}

下面就是正式的 exit 代码了。exit 必然意味着一个任务执行的终止,这也就意味着它将会被从 tasks 删除,如果正在执行这个任务,那么还要进行切换。因此,我们先单独写一个 删除任务的函数 task_remove

代码 12-19 从 tasks 中删除任务(kernel/mtask.c)

void task_remove(task_t *task)
{
    bool need_switch = false; // 是否要进行切换?
    int i;
    if (task->flags == 2) { // 此任务正在运行,如果不运行那就根本不在tasks里,什么都不用干
        if (task == task_now()) { // 是当前任务
            need_switch = true; // 待会还得润
        }
        for (i = 0; i < taskctl->running; i++) {
            if (taskctl->tasks[i] == task) break; // 在tasks中找到当前任务
        }
        taskctl->running--; // 运行任务数量减1
        if (i < taskctl->now) taskctl->now--; // 如果now在这个任务的后面,那now也要前移一个(因为这个任务要删了,后面的要填上来,会整体前移一个)
        for (; i < taskctl->running; i++) {
            taskctl->tasks[i] = taskctl->tasks[i + 1]; // 整体前移,不必多说
        }
        if (need_switch) { // 需要切换
            if (taskctl->now >= taskctl->running) {
                taskctl->now = 0; // now超限,重置为0
            }
            farjmp(0, task_now()->sel); // 跳入到现在的当前任务中
        }
    }
}

task_remove 比较长,因此给了详尽的注释。基本上就是一堆善后工作需要做,核心部分只有中间的三行整体前移。

为什么添加了一个 need_switch 的变量呢?因为如果在最上面的 if 那就切换,那下面的整体前移就根本执行不到,这样就没有删除的作用了。

有了 task_removetask_exit 就非常简单了:

代码 12-20 任务自动退出(kernel/mtask.c)

void task_exit(int value)
{
    task_t *cur = task_now(); // 当前任务
    cur->my_retval.pid = task_pid(cur); // pid变为当前任务的pid
    cur->my_retval.val = value; // val为此时的值
    task_remove(cur); // 删除当前任务
    cur->flags = 4; // 返回值还没人收,暂时还不能释放这个块为可用(0)
}

接下来是 task_wait,等待指定 pid 的进程执行 exit 退出。

代码 12-21 等待任务退出(kernel/mtask.c)

int task_wait(int pid)
{
    task_t *task = &taskctl->tasks0[pid]; // 找出对应的task
    while (task->my_retval.pid == -1); // 若没有返回值就一直等着
    task->flags = 0; // 释放为可用
    return task->my_retval.val; // 拿到返回值
}  

注意,由于此处是判断 pid 是否为 -1 来判断任务是否为退出,应当在初始化任务的时候(即 task_alloc 中)加上对 pid 的设定如下:

代码 12-22 初始化 my_retval(kernel/mtask.c)

            task->tss.iomap = 0x40000000;
            task->my_retval.pid = -1;      // 这里是新增的部分
            task->my_retval.val = -114514; // 这里是新增的部分
            return task;

请自行在 mtask.h 中添加 task_pidtask_exittask_wait 的声明。

接下来是测试用例,直接把完整版 main.c 端上来:

代码 12-23 测试用例(kernel/main.c)

#include "monitor.h"
#include "gdtidt.h"
#include "isr.h"
#include "timer.h"
#include "memory.h"
#include "mtask.h"

task_t *create_kernel_task(void *entry)
{
    task_t *new_task;
    new_task = task_alloc();
    new_task->tss.esp = (uint32_t) kmalloc(64 * 1024) + 64 * 1024 - 4;
    new_task->tss.eip = (int) entry;
    new_task->tss.es = new_task->tss.ss = new_task->tss.ds = new_task->tss.fs = new_task->tss.gs = 2 * 8;
    new_task->tss.cs = 1 * 8;
    return new_task;
}

void task_b_main()
{
    monitor_write("Waiting for task_a to dead...\n");
    int retval = task_wait(0); // kernel_main
    monitor_write("R.I.P. task_a, retval: ");
    monitor_write_hex(retval);
    monitor_write("\nWaiting for 10 seconds (roughly)...\n");
    for (int i = 0; i < 10000000; i++) for (int j = 0; j < 20; j++);
    task_exit(114514);
}

void task_c_main()
{
    monitor_write("Waiting for task_b to dead...\n");
    int retval = task_wait(1); // task_b
    monitor_write("R.I.P. task_b, retval: ");
    monitor_write_dec(retval);
    monitor_write("\nThey're all dead, I must live!!!");
    while (1);
}

void kernel_main() // kernel.asm会跳转到这里
{
    monitor_clear();
    init_gdtidt();
    init_memory();
    init_timer(100);
    asm("sti");

    task_t *task_a = task_init(); // task_a: pid 0
    task_t *task_b = create_kernel_task(task_b_main); // task_b: pid 1
    task_t *task_c = create_kernel_task(task_c_main); // task_c: pid 2
    task_run(task_b);
    task_run(task_c);

    monitor_write("Waiting for 10 seconds (roughly)...\n");
    for (int i = 0; i < 10000000; i++) for (int j = 0; j < 20; j++);

    // 悬停
    task_exit(0xDEADBEEF); // 再见……
}

编译,运行,等待约半分钟后,效果如下:

(图 12-2 成功)

好了,多任务到此为止已经可以结束了。下面我们来加速冲刺,进入到人机交互的第一个里程碑——键盘驱动。