操作系统实现:调度与多任务(一)内核级线程
此处参考:代码实现
内核线程#
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
。创建者线程可以在routine
,routineArgs
指定新线程的函数和参数,KernelThreadCallback
实现如下:
static void KernelThreadCallback(KR_THREAD_ROUTINE routine, void *routineArgs) {
(void)EnableIntr();
routine(routineArgs);
}
根据 ABI 规则,KernelThreadCallback
必须要从 esp + 4, esp + 8
处获取 routine
,routineArgs
。于是在创建线程时还需提前预留空间以保证 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 的线程均插入队尾,确保公平性。
多线程调度的流程如下:
- 中断打开,每一次时钟中断记为一次 tick,每当发生一次 tick,当前运行的线程所持有 tick(当前策略tick就是优先级数)减一,当时间片用尽,进入调度器。
- 调度器运行时确保处于关中断状态:当前线程若为运行状态(RUNNING),则重新插入就绪队列尾部,重置时间片(基于优先级),并切换状态为就绪(READY)。
- 从就绪队列头部取出下一个线程,更新其状态为 RUNNING,通过
KrSwitchTo
进行上下文切换。当KrSwitchTo
ret
时跳转到新线程的 EIP(即KernelThreadCallback
)或者已运行过另一线程的执行流的调度器函数(见上 [[执行流与多任务的实现#将线程换上/换下处理机]] 的过程)以恢复线程执行。
内核在启动时将把自身初始化为 “kernel” 的线程。
作者:himu-qaq
出处:https://www.cnblogs.com/himu-qaq/p/18696743
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)