mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1904 字
5 分钟
任务调度系统:task_t 与上下文切换
2021-07-04

早期计算机一次只能运行一个程序。多任务的出现让计算机看起来能同时做很多事,本质上是快速切换任务造成的错觉。在单核 CPU 上,任何时刻只有一条指令流在执行,所谓”并发”不过是调度器在多个任务之间来回跳转的结果。

多任务的动机#

单任务系统的局限显而易见:一个程序独占 CPU,其他程序只能等待。如果当前程序进入死循环,整个系统就卡住了。时间共享(time-sharing)的思路是给每个任务分配一小段 CPU 时间,用完就换下一个任务运行。这样即使某个任务卡死,其他任务仍然有机会执行。

任务调度系统需要解决四个问题:

  1. CPU 时间分配:如何公平地将 CPU 时间分配给多个任务?
  2. 上下文保存:切换任务时,如何保存和恢复任务的执行状态?
  3. 任务管理:如何创建、销毁和管理任务的元数据?
  4. 资源同步:如何防止多个任务同时访问共享资源?

task_t 结构#

每个任务都需要保存自己的状态信息,包括任务 ID、运行状态、优先级、内核栈指针等。这些信息需要被操作系统持久保存,以便在任务切换时能够恢复。task_t 结构是操作系统中任务的核心数据结构,它包含了任务的所有元数据和运行时状态:

typedef struct task {
/* identity - 任务标识 */
tid_t tid; /* 任务 ID */
pid_t tgid; /* 线程组 ID */
char name[16]; /* 任务名称 */
/* owner process - 所属进程 */
struct process *owner;
/* scheduling - 调度相关 */
task_state_t state; /* 任务状态 */
int priority; /* 优先级 */
int timeslice; /* 时间片 */
/* kernel stack - 内核栈 */
void *kstack_base; /* 栈基址 */
void *kstack_top; /* 栈顶 */
uint32_t kernel_esp; /* 当前栈指针 */
/* saved context - 保存的上下文 */
cpu_context_t ctx; /* CPU 上下文(寄存器) */
/* runqueue node - 运行队列节点 */
struct list_node rq_node;
/* bookkeeping - 其他信息 */
int exit_code; /* 退出码 */
int refcount; /* 引用计数 */
} task_t;

任务状态枚举

typedef enum {
TASK_RUNNING, /* 运行中 */
TASK_RUNNABLE, /* 可运行/就绪 */
TASK_BLOCKED, /* 阻塞中 */
TASK_SLEEPING, /* 睡眠中 */
TASK_ZOMBIE, /* 僵尸态 */
} task_state_t;

CPU 上下文结构

typedef struct cpu_context {
uint32_t ebx; /* ebx 寄存器 */
uint32_t esi; /* esi 寄存器 */
uint32_t edi; /* edi 寄存器 */
uint32_t ebp; /* ebp 寄存器 */
uint32_t esp; /* 栈指针 */
uint32_t eip; /* 指令指针 */
} cpu_context_t;

有了描述任务的数据结构,接下来要解决的是:如何从一个任务切换到另一个。

上下文切换#

当调度器决定切换到另一个任务时,必须先保存当前任务的 CPU 寄存器状态,然后恢复下一个任务的寄存器状态。这个过程称为上下文切换。上下文切换由汇编代码实现,因为需要直接操作 CPU 寄存器。

上下文切换需要保存的寄存器是 callee-saved 寄存器,这些寄存器在函数调用中必须被调用者保存:

  • ebxesiediebp:通用寄存器
  • esp:栈指针
  • eip:指令指针
flowchart TD A[开始上下文切换] --> B[保存当前任务的寄存器] B --> C[保存 ebx, esi, edi, ebp] C --> D[计算并保存 esp] D --> E[保存 eip] E --> F[加载下一个任务] F --> G[恢复下一个任务的寄存器] G --> H[恢复 ebx, esi, edi, ebp] H --> I[恢复 esp] I --> J[跳转到 eip] J --> K[开始执行新任务]

上下文切换解决了”怎么切换”的问题,但还没回答”切换到谁”,这是调度器的职责。

调度器#

调度器负责决定哪个任务应该在 CPU 上运行。它维护一个运行队列,包含所有可运行的任务,并从中选择下一个要执行的任务。调度器的核心函数是 schedule(),它执行以下步骤:

  1. 将当前任务(如果状态为 RUNNABLE)重新加入运行队列
  2. 从运行队列中选择下一个任务
  3. 更新当前任务指针
  4. 执行上下文切换
flowchart TD A[开始调度] --> B{当前任务是否为 RUNNABLE?} B -->|是| C[将当前任务加入运行队列] B -->|否| D[从运行队列选择下一个任务] C --> D D --> E{是否有可运行任务?} E -->|有| F[更新当前任务指针] E -->|无| G[使用 idle 任务] G --> F F --> H{prev == next?} H -->|是| I[无需切换] H -->|否| J[执行上下文切换] J --> K[恢复任务执行]

调度器需要一个数据结构来管理所有就绪的任务,这就是运行队列。

运行队列#

运行队列是调度器的数据源,它维护所有可运行的任务。调度器从队列中取出任务执行,执行完的任务(或时间片用完的任务)重新加入队列。运行队列使用双向链表实现,每个任务结构中嵌入一个链表节点(rq_node)。这种设计避免了额外的内存分配。

graph LR A[Runqueue Head] --> B[Task 1] B --> C[Task 2] C --> D[Task 3] D --> E[NULL] A -.prev.-> A B -.prev.-> A C -.prev.-> B D -.prev.-> C

代码实现#

文件结构#

09.kernel-task/
├── boot/
│ ├── mbr.S
│ └── loader.S
├── kernel/
│ ├── include/
│ │ ├── task.h # 任务结构定义
│ │ ├── scheduler.h # 调度器接口
│ │ └── runqueue.h # 运行队列
│ ├── task/
│ │ ├── task.c # 任务生命周期管理
│ │ ├── scheduler.c # 调度器实现
│ │ └── runqueue.c # 运行队列实现
│ └── arch/x86/
│ └── context_switch.S # 上下文切换汇编
└── Makefile

task_t 结构的设计遵循”最小化”原则,只包含必要字段:

typedef struct task {
tid_t tid; /* 任务 ID */
task_state_t state; /* 任务状态 */
int priority; /* 优先级 */
void *kstack_base; /* 内核栈基址 */
uint32_t kernel_esp; /* 当前栈指针 */
cpu_context_t ctx; /* CPU 上下文 */
struct list_node rq_node; /* 运行队列节点 */
} task_t;

关键设计决策:

  1. 嵌入链表节点rq_node 直接嵌入在 task_t 中,避免了额外的内存分配
  2. 内核栈:每个任务都有自己的内核栈,用于中断处理和系统调用
  3. CPU 上下文:保存任务切换时需要恢复的寄存器状态

线程首次启动时,栈上需要设置一个特殊的结构:

typedef struct switch_stack {
uint32_t edi; /* edi 寄存器 */
uint32_t esi; /* esi 寄存器 */
uint32_t ebp; /* ebp 寄存器 */
uint32_t ebx; /* ebx 寄存器 */
uint32_t edx; /* edx 寄存器 */
uint32_t ecx; /* ecx 寄存器 */
uint32_t eax; /* eax 寄存器 */
uint32_t start_eip; /* 线程入口地址 */
uint32_t unused_retaddr; /* 占位符 */
void (*function)(void *); /* 线程函数 */
void *arg; /* 函数参数 */
} switch_stack_t;

任务创建流程:

flowchart TD A[task_create_kernel] --> B[分配 task_t 结构] B --> C[分配 TID] C --> D[设置任务名称和优先级] D --> E[分配内核栈] E --> F[初始化 switch_stack] F --> G[设置 ctx.eip = resume_thread] G --> H[将任务加入运行队列] H --> I[返回任务指针]

上下文切换流程:

sequenceDiagram participant Schedule participant ContextSwitch participant TaskA participant TaskB Schedule->>ContextSwitch: context_switch(&prev->ctx, &next->ctx) ContextSwitch->>TaskA: 保存 ebx, esi, edi, ebp ContextSwitch->>TaskA: 计算并保存 esp ContextSwitch->>TaskA: 保存 eip ContextSwitch->>TaskB: 恢复 ebx, esi, edi, ebp ContextSwitch->>TaskB: 恢复 esp ContextSwitch->>TaskB: 跳转到 eip TaskB-->>Schedule: 任务继续执行

context_switch.S 汇编实现#

global context_switch
context_switch:
; 保存 callee-saved 寄存器
mov eax, [esp+4] ; prev
mov [eax + 0], ebx ; 保存 ebx
mov [eax + 4], esi ; 保存 esi
mov [eax + 8], edi ; 保存 edi
mov [eax + 12], ebp ; 保存 ebp
; 保存 esp
mov ecx, esp
add ecx, 12 ; 跳过返回地址 + prev + next
mov [eax + 16], ecx ; 保存 esp
; 保存 eip
mov ecx, [esp] ; 获取返回地址
mov [eax + 20], ecx ; 保存 eip
; 切换到 next 任务
mov edx, [esp+8] ; next
; 恢复 next 任务的寄存器
mov ebx, [edx + 0] ; 恢复 ebx
mov esi, [edx + 4] ; 恢复 esi
mov edi, [edx + 8] ; 恢复 edi
mov ebp, [edx + 12] ; 恢复 ebp
; 恢复 esp
mov esp, [edx + 16] ; 恢复 esp
; 跳转到 next 任务的 eip
mov eax, [edx + 20] ; 获取 next->ctx.eip
jmp eax ; 跳转执行

这段汇编做了五件事:

  1. 寄存器保存:按照 cpu_context_t 结构的偏移量保存 ebxesiediebp
  2. esp 计算:跳过栈上的返回地址(4 字节)和两个参数(各 4 字节),得到调用 context_switch 之前的 esp
  3. eip 保存:保存函数调用的返回地址,这是任务恢复后继续执行的地址
  4. 寄存器恢复:从 next 结构中恢复所有寄存器
  5. 跳转执行:使用 jmp 跳转到 next->ctx.eip,而不是 ret

task_create 创建任务#

task_t *task_create_kernel(const char *name, void (*fn)(void *), void *arg, int prio)
{
task_t *task;
/* 分配 task_t 结构 */
task = (task_t *)kmalloc(sizeof(task_t));
if (task == NULL) {
vga_printf("task_create_kernel: kmalloc failed\n");
return NULL;
}
memset(task, 0, sizeof(task_t));
/* 分配 TID */
if (!id_pool_allocate_id(&tid_pool, (uint32_t *)&task->tid)) {
kfree(task);
return NULL;
}
/* 分配内核栈 */
stack_addr = (uint32_t)kmalloc_aligned(KERNEL_STACK_SIZE);
task->kstack_base = (void *)stack_addr;
task->kstack_top = (void *)(stack_addr + KERNEL_STACK_SIZE);
/* 初始化 switch_stack */
uint32_t stack_top = stack_addr + KERNEL_STACK_SIZE;
uint32_t kernel_esp = stack_top - (sizeof(interrupt_frame_t) + sizeof(switch_stack_t));
switch_stack_t *switch_stack = (switch_stack_t *)kernel_esp;
/* 设置线程入口点 */
switch_stack->start_eip = (uint32_t)kernel_thread;
switch_stack->function = (void (*)(void *))fn;
switch_stack->arg = arg;
/* 保存到 task_t */
task->kernel_esp = kernel_esp;
task->ctx.esp = kernel_esp;
task->ctx.eip = (uint32_t)resume_thread;
/* 加入运行队列 */
runqueue_add(rq, task);
return task;
}

task_create_kernel 的核心步骤:使用 kmalloc 分配 task_t 结构和内核栈;在内核栈顶部预留 interrupt_frame_tswitch_stack_t 空间;ctx.eip 设置为 resume_thread,这是线程首次启动的入口;新任务初始状态为 TASK_RUNNABLE,加入运行队列等待调度。

schedule 调度器#

void schedule(void)
{
task_t *prev = current_running_task;
task_t *next = NULL;
runqueue_t *rq = get_global_runqueue();
/* 将当前任务重新加入队列 */
if (prev != NULL && prev->state == TASK_RUNNABLE) {
runqueue_add(rq, prev);
}
/* 选择下一个任务 */
next = runqueue_pop(rq);
/* 更新当前任务 */
current_running_task = next;
next->state = TASK_RUNNING;
/* 执行上下文切换 */
if (prev != next) {
context_switch(&prev->ctx, &next->ctx);
}
}

schedule 的逻辑很直接:当前任务如果是 RUNNABLE 状态,重新加入运行队列(时间片轮转);runqueue_pop 从队列头部取出下一个任务(FIFO);新任务状态设置为 RUNNING;最后调用 context_switch 完成实际的切换。

resume_thread 启动线程#

global resume_thread
resume_thread:
; pop 所有寄存器
pop edi
pop esi
pop ebp
pop ebx
pop edx
pop ecx
pop eax
; 开中断
sti
; ret 指令会 pop start_eip 并跳转到 kernel_thread
ret

resume_thread 只在新线程首次被调度时执行。它从栈上 pop 所有通用寄存器(eaxecxedxebxebpesiedi),然后 sti 开启中断,允许定时器中断触发调度。ret 指令从栈上弹出 start_eipkernel_thread 的地址)并跳转,开始执行用户提供的线程函数。

运行与验证#

编译运行#

cd 09.kernel-task
make clean
make all
make run

预期输出#

Hello, kernel world!
Initializing task subsystem...
Creating test threads...
Threads created: test1 (tid=1), test2 (tid=2)
Starting scheduler...
Thread 1: started
Thread 1: count = 0
Thread 1: before yield
Thread 2: started
Thread 2: count = 0
Thread 2: before yield
Thread 1: after yield
Thread 1: count = 1
...
Thread 1: exiting
Thread 2: exiting

测试代码分析#

kernel.c 中,我们创建了两个测试线程:

void test_thread1(void *arg) {
int i;
for (i = 0; i < 3; i++) {
vga_printf("Thread 1: count = %d\n", i);
schedule_yield();
}
vga_printf("Thread 1: exiting\n");
task_exit(0);
}
void kernel_main(void) {
task_subsystem_init();
/* 创建测试线程 */
task_create_kernel("test1", test_thread1, NULL, 10);
task_create_kernel("test2", test_thread2, NULL, 10);
/* 启动调度器 */
schedule();
}

踩坑记录#

  1. 问题:为什么线程首次运行时需要 resume_thread

    原因:新创建的线程没有之前的上下文,无法直接使用 context_switchresume_thread 从栈上预置的 switch_stack_t 恢复寄存器,然后跳转到 kernel_thread 执行用户提供的线程函数。

  2. 问题:为什么要保存 esp 而不是直接保存栈指针?

    原因context_switch 被调用时,栈指针已经改变。需要保存的是调用 context_switch 之前的栈指针,这样任务恢复时才能回到正确的栈帧。

  3. 问题:为什么使用 jmp 而不是 ret 来跳转到新任务?

    原因ret 指令需要栈上有返回地址,但新任务的栈上可能没有正确的返回地址。jmp 直接跳转到 eip,更加灵活和安全。

小结#

本章实现了多任务系统的基础组件:task_t 结构定义了任务的元数据和运行时状态,上下文切换通过汇编代码保存和恢复 CPU 寄存器,调度器使用简单的 FIFO 算法从运行队列选择任务,运行队列用双向链表维护可运行的任务。这些组件让内核能够创建和管理多个并发执行的线程,但当前的调度策略还很粗糙,没有时间片的概念,也没有抢占机制。下一章将引入时间片轮转(Round-Robin)调度算法和时钟中断驱动的抢占式调度,让任务切换变得公平和自动。

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

任务调度系统:task_t 与上下文切换
https://blog.souloss.com/posts/os/task-scheduling-context-switch/
作者
Souloss
发布于
2021-07-04
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时