到目前为止,内核一直是单线程跑到底——一件事没做完,别的什么都不能干。

这一章让内核同时跑多个进程。每隔一段时间,时钟中断打断当前进程,把 CPU 交给下一个,轮流执行。这就是多任务的核心机制。


上下文是什么

进程被打断之后,下次恢复时要从断点继续执行,不能出错。这意味着必须把 CPU 的状态完整保存下来——这份状态就叫上下文(Context)。

x86-64 ABI 规定了 callee-saved 寄存器(被调用者负责保存的寄存器,C 函数调用约定中,这些寄存器的值在函数调用前后必须保持不变):rbxrbpr12~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 硬件把 riprsprflagscsss 压到内核栈,然后 isr.asm 再把其余寄存器 push 进去,形成完整的 int_regs_t。中断处理完毕执行 iretq 时,CPU 从栈上把这些值 pop 回寄存器。

所以,中断处理函数里修改 regs->ripregs->rspiretq 之后 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 = 8192kmalloc 返回 8 字节对齐的地址,栈顶 stack + 8192 也是对齐的——对齐问题就这样绕过去了。


调度策略:轮转(Round Robin)

目前用的是最简单的 Round Robin(时间片轮转,每个进程轮流获得固定时间片的调度算法):每次时钟中断找下一个 READY 进程,公平轮换,谁也不饿死。

这里每次时钟中断都触发一次切换(10ms 一次),实际操作系统通常给每个进程分配多个时间片(比如 5~20ms),到期才切,减少切换开销。


这一章之后

现在内核能跑多个进程了,但这些进程都在内核态跑,权限和内核一样高。

下一章实现系统调用:把用户态和内核态分开,用户程序只能通过受控的接口请求内核服务,不能直接操作硬件——这是操作系统安全隔离的最后一块拼图。

源码在这里:github.com/tongpengfei/learn-with-ai