自制操作系统 - 内核GDT与任务上下文

内核GDT与任务上下文

Posted by 王富杰 on Sunday, January 21, 2024

一、内核全局描述符表

前面在内核加载器中实现了全局描述符,这里我们把全局描述符写到内核中去,这样管理起来更加的方便。首先定义GDT、段选择子、描述符指针结构体。然后定义全局描述符表初始化函数。

descriptor_t gdt[GDT_SIZE]; // 内核全局描述符表, GDT_SIZE设置128
pointer_t gdt_ptr;          // 内核全局描述符表指针

// 初始化内核全局描述符表
void gdt_init()
{
    DEBUGK("init gdt!!!\n");
    asm volatile("sgdt gdt_ptr");  // 将内核加载器中实现的GDT保存到指针里面
    memcpy(&gdt, (void *)gdt_ptr.base, gdt_ptr.limit + 1);  // 将内核加载器的描述符表拷贝到内核的 gdt 里面
    gdt_ptr.base = (u32)&gdt;         
    gdt_ptr.limit = sizeof(gdt) - 1;
    asm volatile("lgdt gdt_ptr\n");  // 加载内核的GDT.
}

执行完唯一的变化是全局描述符由3个变成了128个。前边3个还是和原来内核加载器的gdt一样,后边新的125个size都是0。

二、任务及上下文

任务就是一个进程或线程,也可以理解为一个执行流。它需要有一个程序入口地址,这可以理解我们在高级语言编程中的线程入口函数。每个进程都有自己的堆栈和寄存器信息。因此进程切换时要保存寄存器信息。

2.1、ABI调用约定

ABI调用(Application Binary Interface)规定了函数调用约定,寄存器的使用规则。哪些寄存器需要保存等信息。Linux系统使用的是System V ABI。需要实现方保存的寄存器有ebx、esi、 edi、 ebp、 esp, 调用完成后这些寄存器的值不变。需要调用方保存的寄存器有eax, ecx, edx。例如eax需要存返回值,是有可能发生改变的。

2.1、任务内存分布

在80386CPU中,内存按照4K大小进行分页, 每一页开始的内存地址最低三位都是0。任务是存储在内存中的,这里给每个进程分配一页大小的内存。如图所示为一页内存中进程的信息: 图片加载失败 PCB是进程控制块的缩写。最高位置是栈底,栈向下增长。

2.2、进程上下切换实现

我们首先来定义进程相关的结构

typedef u32 target_t();  // 定义进程的入口地址函数

typedef struct task_t
{
    u32 *stack; // 内核栈,每个任务自己的栈分配一页内存
} task_t;

typedef struct task_frame_t  // ABI约定要保存的寄存器,在进行切换前要保存。
{
    u32 edi;
    u32 esi;
    u32 ebx;
    u32 ebp;
    void (*eip)(void);
} task_frame_t;

void task_init();  // 任务初始化函数

接下来我们来实现一个在屏幕上打印A和B的进程,轮流调度这两个进程,实现轮流打印A和B的功能。代码实现:

#define PAGE_SIZE 0x1000  // 定义一页的大小为4K

//定义进程的内存位置x1000,这个内存位置我们放了内核加载器,当然现在已经进入了内核,内核加载器已经没用了
task_t *a = (task_t *)0x1000;  
task_t *b = (task_t *)0x2000;

extern void task_switch(task_t *next);

task_t *running_task() // 获取当前运行的进程
{
    asm volatile(
        "movl %esp, %eax\n"   // 获取当前进程的esp,即栈顶
        "andl $0xfffff000, %eax\n");  // 获取当前进程内存页开始的位置,即得到当前进程内存所在页
}

void schedule()
{
    task_t *current = running_task();
    task_t *next = current == a ? b : a;  // 如果当前是a就换b,否则就换a。交替执行
    task_switch(next);
}

u32 thread_a()
{
    while (true)
    {
        printk("A");
        schedule();
    }
}

u32 thread_b()
{
    while (true)
    {
        printk("B");
        schedule();
    }
}

static void task_create(task_t *task, target_t target)
{
    u32 stack = (u32)task + PAGE_SIZE;  // 设置进程所在页的最高位为栈顶

    stack -= sizeof(task_frame_t);   // 内存的最高位存储task_frame_t结构
    task_frame_t *frame = (task_frame_t *)stack;
    frame->ebx = 0x11111111;   // 这里我们用不到这些个寄存器,随便存一些值
    frame->esi = 0x22222222;
    frame->edi = 0x33333333;
    frame->ebp = 0x44444444;
    frame->eip = (void *)target; // eip指向程序的入口位置

    task->stack = (u32 *)stack;  // 将task结构的栈指向这里
}

void task_init()
{
    task_create(a, thread_a);  // 创建两个进程
    task_create(b, thread_b);
    schedule();
}

从这个代码我们可以看到,创建两个任务,分配到内存0x1000和0x2000的位置。然后交替调度两个任务。这里重点是task_switch函数,这个函数用汇编实现,代码如下:

task_switch:
    push ebp   ; 保存栈帧
    mov ebp, esp

    push ebx   ; 保存ABI约定的三个寄存器
    push esi
    push edi

    mov eax, esp;
    and eax, 0xfffff000 ; 获取当前的任务信息

    mov [eax], esp   ; 保存当前线程的栈顶

    mov eax, [ebp + 8] ; 参数传进来的next,即下一个进程的内存地址
    mov esp, [eax]     ; 恢复next线程的栈顶, 注意这里esp已经指向下一个任务的栈顶了

    pop edi   ; 恢复下一个任务的寄存器
    pop esi
    pop ebx
    pop ebp

    ret

三、调试进程切换

现在我们启动调试,看下0x1000出的内存,如下所示: 图片加载失败 如图可以看到,在0x1000这一页内存位置,最高位存储是我们代码中设置的寄存器值,低位是PCB信息,PCB的最低位也是保存的esp。和我们前边给的图一一对应。

这里我们重点关注一下 eip 寄存器,它被设置在栈底的位置,task_switch函数在弹出保存的栈之后,最后一个就是进程的函数入口地址,ret指令会弹出这个地址给eip寄存器,CPU就会继续去eip指向的地址取指令并运行,这样就实现了进程切换。

「真诚赞赏,手留余香」

WangFuJie Blog

真诚赞赏,手留余香

使用微信扫描二维码完成支付