到目前为止,内核一直是单线程跑到底——一件事没做完,别的什么都不能干。
这一章让内核同时跑多个进程。每隔一段时间,时钟中断打断当前进程,把 CPU 交给下一个,轮流执行。这就是多任务的核心机制。
上下文是什么
进程被打断之后,下次恢复时要从断点继续执行,不能出错。这意味着必须把 CPU 的状态完整保存下来——这份状态就叫上下文(Context)。
x86-64 ABI 规定了 callee-saved 寄存器(被调用者负责保存的寄存器,C 函数调用约定中,这些寄存器的值在函数调用前后必须保持不变):rbx、rbp、r12~r15。加上程序指针 rip 和栈指针 rsp,这些就是切换时需要保存和恢复的全部。
typedef struct {
uint64_t r15, r14, r13, r12;
uint64_t rbx, rbp;
uint64_t rip; // 下次从哪里继续执行
uint64_t rsp; // 栈在哪里
} context_t;
每个进程一份 context,切换时:把当前进程的寄存器存入 ctx,再把下一个进程 ctx 里的值写回寄存器——CPU 就"变身"成另一个进程了。
进程结构
#define MAX_PROCS 8
#define STACK_SIZE 8192 // 每个进程 8KB 内核栈
typedef enum {
PROC_UNUSED = 0,
PROC_READY,
PROC_RUNNING,
} proc_state_t;
typedef struct {
context_t ctx;
uint8_t *stack;
proc_state_t state;
uint32_t pid;
} process_t;
static process_t procs[MAX_PROCS];
static int current_proc = 0;
状态机很简单:UNUSED(槽位空闲)→ READY(等待调度)→ RUNNING(正在跑)→ 被打断后回到 READY。
创建进程
void proc_create(void (*entry)()) {
for (int i = 0; i < MAX_PROCS; i++) {
if (procs[i].state != PROC_UNUSED) continue;
procs[i].pid = i;
procs[i].state = PROC_READY;
procs[i].stack = kmalloc(STACK_SIZE);
// rip 指向入口函数,rsp 指向栈顶
procs[i].ctx.rip = (uint64_t)entry;
procs[i].ctx.rsp = (uint64_t)(procs[i].stack + STACK_SIZE);
// 其余寄存器清零
procs[i].ctx.r15 = procs[i].ctx.r14 = procs[i].ctx.r13 = 0;
procs[i].ctx.r12 = procs[i].ctx.rbx = procs[i].ctx.rbp = 0;
return;
}
}
核心就两行:rip = entry(进程从这里开始执行),rsp = 栈顶(x86 栈向下生长,所以要从高地址开始)。
调度器:在时钟中断里切换
抢占式调度(Preemptive Scheduling):进程不用主动让出 CPU,时钟中断强行打断它。
时钟中断的处理函数(IRQ0):
// isr.c
void isr_handler(int_regs_t *regs) {
if (regs->int_no == 32) { // IRQ0 = 向量号 32
pit_tick();
sched_tick(regs); // 调度
pic_eoi(0);
}
}
sched_tick 直接操作中断栈帧 regs——这是精髓所在:
void sched_tick(int_regs_t *regs) {
// 1. 保存当前进程
if (procs[current_proc].state == PROC_RUNNING) {
procs[current_proc].state = PROC_READY;
procs[current_proc].ctx.rip = regs->rip;
procs[current_proc].ctx.rsp = regs->rsp;
procs[current_proc].ctx.rbp = regs->rbp;
procs[current_proc].ctx.rbx = regs->rbx;
procs[current_proc].ctx.r12 = regs->r12;
procs[current_proc].ctx.r13 = regs->r13;
procs[current_proc].ctx.r14 = regs->r14;
procs[current_proc].ctx.r15 = regs->r15;
}
// 2. 轮转找下一个 READY 进程
int next = current_proc;
for (int i = 1; i <= MAX_PROCS; i++) {
int n = (current_proc + i) % MAX_PROCS;
if (procs[n].state == PROC_READY) { next = n; break; }
}
// 3. 把下一个进程的上下文写入中断栈
current_proc = next;
procs[current_proc].state = PROC_RUNNING;
regs->rip = procs[current_proc].ctx.rip;
regs->rsp = procs[current_proc].ctx.rsp;
regs->rbx = procs[current_proc].ctx.rbx;
regs->r12 = procs[current_proc].ctx.r12;
regs->r13 = procs[current_proc].ctx.r13;
regs->r14 = procs[current_proc].ctx.r14;
regs->r15 = procs[current_proc].ctx.r15;
}
为什么修改 regs 就能切换进程?
时钟中断进来时,CPU 硬件把 rip、rsp、rflags、cs、ss 压到内核栈,然后 isr.asm 再把其余寄存器 push 进去,形成完整的 int_regs_t。中断处理完毕执行 iretq 时,CPU 从栈上把这些值 pop 回寄存器。
所以,中断处理函数里修改 regs->rip 和 regs->rsp,iretq 之后 CPU 就跳到了新进程的 rip,用着新进程的栈——切换完成。
不需要专门写汇编的上下文切换函数,借用中断的进出机制就够了。
启动调度
void kernel_main() {
// ... 初始化各模块 ...
proc_init();
proc_create(proc_a);
proc_create(proc_b);
// kernel_main 进 idle 循环,靠时钟中断驱动调度
while (1) { __asm__ volatile ("hlt"); }
}
两个测试进程:
static void proc_a() {
while (1) {
serial_print("[A] running\r\n");
for (volatile int i = 0; i < 1000000; i++);
}
}
static void proc_b() {
while (1) {
serial_print("[B] running\r\n");
for (volatile int i = 0; i < 1000000; i++);
}
}
输出:
[A] running
[A] running
[A] running
[B] running
[B] running
[A] running
[B] running
...
A 和 B 交替出现,谁也没有主动 yield,全靠时钟中断强制切换。
一个踩坑:第一次进程启动
创建进程时,ctx.rip 指向入口函数,但进程还没跑过,所以 ctx.rsp 是我们手动设的栈顶,而不是从某次中断里保存下来的真实栈状态。
第一次调度到某个进程时,iretq 用的是初始化时设的 rip/rsp,直接跳到入口函数,从一个干净的栈开始——这是对的。
但如果初始化时 rsp 没有 8 字节对齐,调用 C 函数时 SSE 指令可能因为栈未对齐触发 #GP(General Protection Fault,通用保护异常)。STACK_SIZE = 8192,kmalloc 返回 8 字节对齐的地址,栈顶 stack + 8192 也是对齐的——对齐问题就这样绕过去了。
调度策略:轮转(Round Robin)
目前用的是最简单的 Round Robin(时间片轮转,每个进程轮流获得固定时间片的调度算法):每次时钟中断找下一个 READY 进程,公平轮换,谁也不饿死。
这里每次时钟中断都触发一次切换(10ms 一次),实际操作系统通常给每个进程分配多个时间片(比如 5~20ms),到期才切,减少切换开销。
这一章之后
现在内核能跑多个进程了,但这些进程都在内核态跑,权限和内核一样高。
下一章实现系统调用:把用户态和内核态分开,用户程序只能通过受控的接口请求内核服务,不能直接操作硬件——这是操作系统安全隔离的最后一块拼图。