操作系统实现:调度与多任务(一)内核级线程

此处参考:代码实现

内核线程#

HimuOS 使用内核级线程作为基本执行和调度单元。每个进程最少拥有一个线程,初始线程被称为 主线程。

PCB#

在 HimuOS 中的 PCB 大小固定为一个自然页。值得注意的是,每个执行单元在内核模式时的栈总是位于PCB内作为上下文的部分被管理。

struct KR_INTR_STACK {
    uint32_t IntrId;
    uint32_t EDI;
    uint32_t ESI;
    uint32_t EBP;
    uint32_t ESPDummy;
    uint32_t EBX;
    uint32_t EDX;
    uint32_t ECX;
    uint32_t EAX;
    uint32_t GS;
    uint32_t FS;
    uint32_t ES;
    uint32_t DS;

    uint32_t ErrorCode;
    void     (*EIP)(void);
    uint32_t CS;
    uint32_t EFlags;
    void    *ESP;
    uint32_t SS;
};

struct KR_THREAD_STACK {
    uint32_t EBP;
    uint32_t EBX;
    uint32_t EDI;
    uint32_t ESI;

    void               (*EIP)(KR_THREAD_ROUTINE *routine, void *routineArgs);
    void               (*Reserved)(void);
    KR_THREAD_ROUTINE *Routine;
    void              *RoutineArgs;
};

#define KR_TASK_NAME_LEN 16

struct KR_TASK_STRUCT {
    /* 内核栈指针(Kernel Stack Pointer)存储任务在内核态执行时的栈顶地址 */
    uint32_t *KrnlStack;
    /* 任务状态(Thread State)*/
    enum KR_THREAD_STATE State;
    /* 任务名称(Task Name) */
    char Name[KR_TASK_NAME_LEN];
    /* 调度优先级(Scheduling Priority)值越大优先级越高 */
    uint8_t Priority;
    /* 剩余时间片(Remaining Time Slice)记录任务在当前调度周期内剩余的可运行时间单位 */
    uint8_t RemainingTicks;
    /* 累计运行时间(Elapsed Ticks)统计任务自创建以来消耗的总时钟滴答数析 */
    uint32_t ElapsedTicks;
    /* 调度队列链节点(Scheduler Queue Link) */
    struct KR_LIST_ELEMENT SchedTag;
    /* 全局任务链表节点(Global Task List Link) */
    struct KR_LIST_ELEMENT GlobalTag;
    /* 页目录指针(Page Directory Pointer)*/
    uint32_t *PageDir;
    /* 栈溢出保护值(Stack Canary) */
    uint32_t StackCanary;
};

将线程换上/换下处理机#

为什么 PCB 要如此设计?考虑线程调度(换下/换上处理机)的相关细节。

执行线程发生改变必然是由中断导致的,中断发生时,将会:

  • 根据 IDT 选择子找到中断处理程序,执行特权级检测
  • 如果特权级发生改变,SS、ESP将被压入新栈中,并根据 TSS 修改 SP,ESP,CS,EIP
  • 对于中断门,CPU 会将 EFLAGS 的 TF,NT位置为0,压入栈中
  • 旧 CS,EIP 压入栈中
  • 如果该中断提供错误码,则将错误码 (ERROR_CODE) 压入栈中。在我们的设计中所有中断都会压入错误码。
  • 执行中断处理程序,特别的,我们在处理中断之前会将所有之前没提到的寄存器全部压入栈中
  • 中断结束后以上压入的数据将被恢复。

中断发生后会将这些数据压入栈中,这就是数据结构 KR_INTR_STACK 的由来,其中字段的顺序是固定的。

发生时钟中断后,如果调度处理程序发现当前线程(curr)时间片已经结束,于是使用函数 KrSchedule 从就绪队列中选择一线程,使用 KrSwitchTo 换上另外一个就绪线程(next)的上下文。

[bits 32]

section .text

global KrSwitchTo
KrSwitchTo:
	push esi
	push edi
	push ebx
	push ebp
	
	mov eax, [esp + 20]
	mov [eax], esp
	mov eax, [esp + 24]
	mov esp, [eax]
	
	pop ebp
	pop ebx
	pop edi
	pop esi
	
	ret
  • KrSwitchTo 作用仅仅是切换栈上下文,也即单纯的将 curr 的ESP保存到 curr 的栈中,再将 ESP 指向 next 线程的栈上。
  • 由于 KrSchedule 是 C 函数,但是 KrSwitchTo 执行中线程栈就已经完成了切换(也即 KrSwitchTo 代码的前半段和后半段执行的线程不同)。我们需要在切换栈之前,将 ABI 所规定的寄存器(对应 KR_THREAD_STACK 的前四个字段)保存到 curr 的栈中,之后线程切换回来 (也即 next 被换上处理器得到执行,next 线程几乎必然会修改这四个寄存器, 等到curr 再度被换上处理器时)curr 将回来执行 KrSwitchTo 后半段的代码,恢复之前的数据,ABI 所规定的寄存器数据恢复,于是 KrSchedule 可以正确的返回到中断处理程序,中断可以被正确返回,之前线程进入中断前的数据(KR_INTR_STACK)可以被回收,上下文被恢复,线程 curr 恢复执行。
  • 隐式EIP处理ret指令利用新栈中的返回地址实现指令指针(EIP)的切换,无需显式操作。
  • 对于新线程的切换,在部分细节处理方面有所不同,参见下一节。

启动一个新的线程#

一个新的线程必然是由一个正在运行的线程创建/切换过去的。显然,在第一次调度时,新的线程在上处理机之前没有发生过任何中断,没有中断栈的数据,也没有发生过任何函数调用,新线程的函数是由正在运行的线程指定。

我们的线程切换程序总是假定,在目标线程的栈指针的高处,依次有:

  • ABI Regs Context 对应 KR_THREAD_STACK 的前四个字段,用于保存上下文(见上)。
  • 返回地址:KrSwitchTo 执行完后的返回地址
  • 中断栈

于是在创建线程时,我们需要提前为新线程分配好空间并初始化这些数据,如图所示,为线程/进程刚被创建完毕时的内存分布:

![[stack.drawio.png]]

当新的线程被第一次换上(从esp被置为该线程的栈指针时开始),首先执行的是 KrSwitchTo 的后半部分,于是栈指针指向“KrSwitchTo return address” 处作为返回地址。与常规的、已经至少得到一次运行的线程不同,KrSwitchTo 将返回到 KernelThreadCallback 而不是 KrSchedule,在 KernelThreadCallback 新线程的函数得以执行。

特别的,此时栈顶(也即KrSwitchTo 返回地址)指向内部 C 函数:KernelThreadCallback。创建者线程可以在routineroutineArgs 指定新线程的函数和参数,KernelThreadCallback 实现如下:

static void KernelThreadCallback(KR_THREAD_ROUTINE routine, void *routineArgs) {
    (void)EnableIntr();
    routine(routineArgs);
}

根据 ABI 规则,KernelThreadCallback 必须要从 esp + 4, esp + 8 处获取 routineroutineArgs。于是在创建线程时还需提前预留空间以保证 KernelThreadCallback 可以在正确的位置获取参数,Return Address 可以取任意值。

侵入式双向链表#

侵入式链表可以在 C 语言中作为高效 “泛型/模板” 链表的替代。

参见:

/**
 * HIMU OPERATING SYSTEM
 *
 * File: list.h
 * Kernel sturucture: double linked list
 * Copyright (C) 2024 HimuOS Project, all rights reserved.
 */

#ifndef __HIMUOS_KERNEL_LIST_H
#define __HIMUOS_KERNEL_LIST_H

#include "../krnltypes.h"
#include <stddef.h>

#define CONTAINER_OFFSET(type, field)      (size_t)(&((type *)0)->field)

#define CONTAINER_OF(address, type, field) ((type *)((uint32_t)(address) - CONTAINER_OFFSET(type, field)))

struct KR_LIST_ELEMENT {
    struct KR_LIST_ELEMENT *Prev;
    struct KR_LIST_ELEMENT *Next;
};

struct KR_LIST {
    struct KR_LIST_ELEMENT Header;
    struct KR_LIST_ELEMENT Tail;
};

void KrInitializeList(struct KR_LIST *list);

void KrInsertElement(struct KR_LIST_ELEMENT *insertPos, struct KR_LIST_ELEMENT *element);

void KrInsertListHeader(struct KR_LIST *list, struct KR_LIST_ELEMENT *element);

void KrInsertListTail(struct KR_LIST *list, struct KR_LIST_ELEMENT *element);

void KrRemoveElement(struct KR_LIST_ELEMENT *element);

struct KR_LIST_ELEMENT *KrListPopHeader(struct KR_LIST *list);

BOOL KrListHasElement(struct KR_LIST *list, struct KR_LIST_ELEMENT *element);

BOOL KrListIsEmpty(struct KR_LIST *list);

size_t KrListLength(struct KR_LIST *list);

#endif //! __HIMUOS_KERNEL_LIST_H

简单示例如下:

	struct KR_LIST myList;
    KrInitializeList(&myList);

    struct MyData data1 = {.Value = 10};
    struct MyData data2 = {.Value = 20};
    struct MyData data3 = {.Value = 30};

    // 插入到链表尾部
    KrInsertListTail(&myList, &data1.Element);
    KrInsertListTail(&myList, &data2.Element);
    KrInsertListHeader(&myList, &data3.Element);

    struct KR_LIST_ELEMENT *current = myList.Header.Next;
    while (current != &myList.Tail) {
        struct MyData *item = CONTAINER_OF(current, struct MyData, Element);
        PrintInt(item->Value);
        PrintStr(" -> ");
        current = current->Next;
    }
    PrintStr("END\n");

    struct KR_LIST_ELEMENT *first     = KrListPopHeader(&myList);
    struct MyData          *firstItem = CONTAINER_OF(first, struct MyData, Element);
    PrintStr("POP: ");
    PrintInt(firstItem->Value);

多线程调度#

具体实现参见:sched.c

总的来说,HimuOS 调度策略如下:

  • 采用时间片轮转(Round-Robin)策略,线程时间片由优先级决定,时间片用完后重新排队。
  • 就绪队列为 FIFO 结构,新线程和让出 CPU 的线程均插入队尾,确保公平性。

多线程调度的流程如下:

  1. 中断打开,每一次时钟中断记为一次 tick,每当发生一次 tick,当前运行的线程所持有 tick(当前策略tick就是优先级数)减一,当时间片用尽,进入调度器。
  2. 调度器运行时确保处于关中断状态:当前线程若为运行状态(RUNNING),则重新插入就绪队列尾部,重置时间片(基于优先级),并切换状态为就绪(READY)。
  3. 从就绪队列头部取出下一个线程,更新其状态为 RUNNING,通过 KrSwitchTo 进行上下文切换。当 KrSwitchTo ret 时跳转到新线程的 EIP(即 KernelThreadCallback)或者已运行过另一线程的执行流的调度器函数(见上 [[执行流与多任务的实现#将线程换上/换下处理机]] 的过程)以恢复线程执行。

内核在启动时将把自身初始化为 “kernel” 的线程。

作者:himu-qaq

出处:https://www.cnblogs.com/himu-qaq/p/18696743

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   Himu  阅读(11)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示