在这里插入图片描述

在上一章中,我们成功解耦了硬件触发与数据消费,利用环形缓冲区和 HLT 指令让 CPU 告别了愚蠢的轮询。然而,为了防止因未注册处理程序而引发的三重故障(Triple Fault),我们在 8259A 的 IMR 中封锁了 0 号引脚——时钟中断(IRQ0)。

这导致目前的内核依然是一个“单线程”控制台。本章将对 8253 PIT 定时芯片调频,并利用时钟嘀嗒(Tick)作为燃料,搭建操作系统最核心的调度机制:基于时间片的抢占式多任务(线程)上下文切换


一、8253 PIT 芯片调频:每秒 100 次的“时间片”

在这里插入图片描述

Intel 8253/8254 PIT(可编程间隔定时器)输入时钟频率固定为 1193182  Hz 1193182\text{ Hz} 1193182 Hz。内部 16 位计数器每收到一个时钟脉冲减 1,减到 0 时通过主片 IRQ0 向 CPU 发一次中断。

若希望系统每 10 毫秒( 100  Hz 100\text{ Hz} 100 Hz)触发一次时钟中断,计数初值:

N = 1193182  Hz 100  Hz ≈ 11932 = 0x2E9C N = \frac{1193182\text{ Hz}}{100\text{ Hz}} \approx 11932 = \text{0x2E9C} N=100 Hz1193182 Hz11932=0x2E9C

8253 控制字端口(0x43)与计数端口(0x40)

写入初值前,必须先向 0x43 发送控制字。控制字 0x3600110110b)含义:

  • Bit 7:6 (00):选择通道 0
  • Bit 5:4 (11):先读写低字节,再读写高字节
  • Bit 3:1 (011):模式 3(方波发生器,适合做时钟嘀嗒)
  • Bit 0 (0):二进制计数

二、核心直觉:CPU 其实一次只能跑一个任务

x86 CPU 同一时刻只有一套寄存器:EIP(下一条指令)、ESP(栈顶)、EAXEDIEFLAGS(CPU 的32 位标志寄存器) 等。
所谓“两个任务同时跑”,真相是:

每隔约 10ms,把当前任务的整套寄存器“拍照”存进它自己的栈,再换另一套栈顶指针,把另一张照片弹回寄存器——CPU 就以为自己一直在跑同一个程序。

因此必须准备两样东西:

对象 作用
每个任务一块独立内核栈stack_a / stack_b 存放该任务被打断时的寄存器快照;任务私有的局部变量、函数调用帧也在这里
任务控制块 TCBtask_t 记住“这张照片现在存在哪”——核心字段就是 esp(该任务栈顶指针)

全局指针 current_task 始终指向“此刻 CPU 正在为谁服务”的那块 TCB。

typedef struct {
    uint32_t* esp;          /* 必须放在首位:该任务被挂起时的栈顶 */
    uint32_t  pid;
    uint32_t  ticks;        /* 剩余时间片 */
    uint32_t  priority;     /* 每次调度重置的时间片配额 */
    uint32_t  stack_magic;  /* 栈溢出检测魔数 */
} task_t;

为什么 esp 必须是第一个成员?
汇编里 mov eax, [current_task] 得到 TCB 地址后,无需加偏移,mov esp, [eax] / mov [eax], esp 就能直接读写栈顶。


三、栈上到底长什么样?(寄存器快照的内存布局)

保护模式下,时钟中断一旦发生,硬件会自动做三件事(你写的代码还没开始跑):

  1. 把当前 EFLAGSCSEIP 依次压入当前正在用的那条栈
  2. 清掉 EFLAGS.IF(关中断,避免嵌套打乱现场)
  3. 从 IDT 取出 asm_irq0_handler 的地址,跳进去

我们的 ISR 入口第一句是 pushad,再把 8 个通用寄存器也压进去。
于是任意一次被打断的任务,其栈顶附近必然呈现下面这张“标准照片”(高地址在上):

  高地址(栈底方向)
  +------------------+
  |     EFLAGS       |  ← 硬件压入:打断瞬间的标志寄存器(含 IF)
  +------------------+
  |       CS         |  ← 硬件压入:代码段
  +------------------+
  |      EIP         |  ← 硬件压入:【关键】被打断的那条指令的下一条地址
  +------------------+
  |      EAX         |  ┐
  |      ECX         |  │
  |      EDX         |  │
  |      EBX         |  ├─ pushad 压入(顺序固定)
  |      ESP'        |  │   ESP' 是 pushad 前的 ESP 快照
  |      EBP         |  │
  |      ESI         |  │
  |      EDI         |  ┘
  +------------------+  ← 此时的 ESP(也就是要写入 TCB->esp 的值)
  低地址(栈顶方向)

iret 会按相反顺序弹出:先 popad 恢复通用寄存器,再弹出 EIP/CS/EFLAGS,CPU 就从当初被打断的那条指令原样继续

新任务还没跑过:用 task_create 人工伪造同一张照片

task_B 在第一次被调度前从未执行过,栈上空空如也。若直接 popad; iret 会弹出垃圾。
所以创建时在 stack_b 高地址端预先写好与上面完全同构的布局:

伪造内容 取值 含义
EFLAGS 0x00000202 bit9(IF)=1,切入后允许再被时钟打断
CS 0x08 内核代码段
EIP task_B 函数地址 第一次 iret 后从函数入口开跑
8 个通用寄存器 全 0 首次运行无历史现场

task->esp 指向这张伪造照片的最低端(EDI 槽)。
首次切入中途切回走的是同一条恢复路径:mov esp, [tcb]; popad; iret——区别只在于栈里 EIP 是“函数入口”还是“上次打断处”。


四、完整推演:A 跑到一半 → 切到 B → 再切回 A

下面用时间线把“执行一半被抢走、切回还能接着跑”拆开。假设:

  • current_task == &task_a,CPU 正在 task_A 的延时循环里,比如 i == 12345
  • 此时寄存器大致是:EIP = 延时循环里某条指令ECX/其它 保存着循环相关值,ESP 落在 stack_a
  • A 的时间片耗尽,本次 IRQ0 决定切换到 B

阶段 0:只有 A 在跑(B 在旁等待)

  CPU 寄存器              stack_a(A 正在用)         stack_b(B 休眠)
  ┌──────────┐            ┌────────────┐              ┌────────────┐
  │ EIP=循环 │ ─────────► │ A 的调用帧 │              │ 伪造的照片 │
  │ ESP=sa+… │            │ …          │              │ EIP=task_B │
  │ EAX=…    │            │            │              │ …          │
  └──────────┘            └────────────┘              └────────────┘
       ▲                        ▲                           ▲
       └── current_task ────────┘                     task_b.esp 指这里

B 此时不占有 CPU,它的全部“未来”都冻在 stack_b + task_b.esp 里。

阶段 1:时钟到 → 硬件先帮 A 存好“回程票”

IRQ0 到达,CPU 还在 A 的栈上自动压入:

stack_a:
  …(A 原来的局部变量)
  [EFLAGS_A]   ← 打断前的 IF 等标志
  [CS]
  [EIP_A]      ← 指向 i==12345 之后那条指令!!!

然后跳进 asm_irq0_handler注意:此时还没有换栈,ESP 仍属于 A。

阶段 2:pushad + 把 ESP 存进 TCB(给 A 建档)

pushad                      ; 再压 8 个通用寄存器 → 照片拍完
mov eax, [current_task]     ; eax = &task_a
mov [eax], esp              ; task_a.esp = 当前栈顶(整张照片的底部)

此刻 task_a.esp 记住了:A 的现场完整躺在 stack_a 的这个地址
哪怕接下来 current_task 被改成 B,A 也不会丢——照片在它自己的栈里。

stack_a(已封存)                 TCB
┌─────────────────┐              task_a.esp ──────┐
│ EFLAGS / CS     │                               │
│ EIP_A (=12345+) │◄── 切回时从这里继续            │
│ EAX…EDI         │                               ▼
└─────────────────┘◄── esp 快照 ──────────────  [esp 指针]

阶段 3:C 调度只改指针,不碰寄存器

void c_irq0_handler(void) {
    if (current_task->ticks > 0) {
        current_task->ticks--;
        return;          /* 时间片未用完:不切换,后面会恢复同一条栈 */
    }
    current_task->ticks = current_task->priority;
    schedule();          /* current_task = &task_b */
}

调度器只做一件事:让 current_task 指向另一块 TCB。
真正的“换人”发生在下一句汇编换 ESP

顺序铁律:必须先 mov [old], esp,再 call c_irq0_handler
若先调度再保存,会把 A 的 ESP 写进 task_b,两张照片全毁。

阶段 4:换 ESP = 换任务(CPU 瞬间站到 B 的栈上)

mov eax, [current_task]     ; 现在是 &task_b
mov esp, [eax]              ; ESP 指向 stack_b 上那张照片!
; … EOI …
popad                       ; 弹出的是 B 的 EAX…EDI
iret                        ; 弹出 B 的 EIP/CS/EFLAGS → 跳进 task_B 或上次断点

换栈之后:

  • CPU 的 ESP 不再指向 stack_a
  • popad / iret 读的全是 B 栈上的数据
  • A 的寄存器值仍安全地冻在 stack_a,通过 task_a.esp 可找回
  换栈前:ESP → stack_a(正在保存 A)
  换栈后:ESP → stack_b(开始恢复 B)

  对 CPU 而言,世界瞬间变成了 B 的世界。

阶段 5:B 跑一段时间后,同样被打断,再切回 A

若干个 tick 后,B 时间片耗尽,过程对称:

  1. 硬件把 B 的 EIP_B(B 执行到一半的位置)压进 stack_b
  2. pushadtask_b.esp = esp(更新 B 的照片,覆盖/接在旧快照逻辑之上)
  3. schedule()current_task = &task_a
  4. esp = task_a.esp → 回到阶段 2 封存的那张 A 的照片
  5. popad 恢复 A 当年的 EAX…EDI
  6. iret 弹出 当初的 EIP_A

于是 CPU 从 i == 12345 的下一条指令接着跑,A 完全不知道中间被抢走过 CPU——局部变量、循环计数、返回地址都还在自己的栈上原封不动。

current_task stack_b stack_a CPU 寄存器 8253 IRQ0 current_task stack_b stack_a CPU 寄存器 8253 IRQ0 A 正在执行(i=12345) B 执行一段时间… 中断:自动压 EFLAGS/CS/EIP_A 到 stack_a pushad(拍完 A 的照片) task_a.esp = ESP schedule → 指向 task_b ESP = task_b.esp(换栈) popad + iret → 进入/恢复 B 再次中断,照片打到 stack_b task_b.esp = ESP;schedule → task_a ESP = task_a.esp(回到旧照片) popad + iret → EIP_A,A 从 12345 继续

对照表:各寄存器在切换里扮演什么角色

寄存器 / 内存 打断时(保存) 恢复时(加载) 一句话
EIP 硬件压栈:记住“跑到哪一行” iret 弹出 能否“接着跑”全靠它
CS / EFLAGS 硬件压栈 iret 弹出 段与中断开关一并恢复
EAX…EDI pushad 压栈 popad 弹出 运算中间结果不能丢
ESP 拍完照后写入 TCB->esp 从新 TCB->esp 读回 换任务 = 换 ESP
current_task 指向旧 TCB schedule 后指向新 TCB 告诉内核“下一张照片在谁那”

两种“不切换”也要理解

时钟每 10ms 都进 asm_irq0_handler,但只有 ticks == 0schedule()

  1. pushad + task_a.esp = esp(照样建档)
  2. c_irq0_handler 只做 ticks--不改 current_task
  3. esp = task_a.esp(拿回同一张照片)
  4. popad; iret → 回到 A 刚才的位置

所以:进时钟中断 ≠ 一定换任务;换任务的充要条件是 current_task 变了,且随后 mov esp, [eax] 加载了另一块栈。


五、完整源码实现

在第 14 章基础上新增 task.h / task.c / task.s,并在 utilities 中补充通用的 register_interrupt_handler,用于向 IDT 注册任意向量。

1. kernel/task.h

#ifndef TASK_H
#define TASK_H

#include <stdint.h>

#define STACK_MAGIC     0x55AA55AA
#define TASK_STACK_SIZE 1024

typedef struct {
    uint32_t* esp;
    uint32_t  pid;
    uint32_t  ticks;
    uint32_t  priority;
    uint32_t  stack_magic;
} task_t;

void init_multitasking(void);
void task_create(task_t* task, void (*function)(void),
                 uint32_t pid, uint32_t priority, uint32_t* stack_space);
void schedule(void);
void switch_to_current_task(void);  /* 首次切入 current_task,不再返回 */

#endif

2. kernel/task.c

#include "task.h"
#include "utilities.h"

task_t task_a;
task_t task_b;
uint32_t stack_a[TASK_STACK_SIZE];
uint32_t stack_b[TASK_STACK_SIZE];

task_t* current_task = &task_a;

extern void asm_irq0_handler(void);
extern void init_8253_raw(void);

static void task_A(void)
{
    for (;;) {
        print_char('A');
        for (volatile int i = 0; i < 500000; i++)
            ;
    }
}

static void task_B(void)
{
    for (;;) {
        print_char('B');
        for (volatile int i = 0; i < 500000; i++)
            ;
    }
}

void task_create(task_t* task, void (*function)(void),
                 uint32_t pid, uint32_t priority, uint32_t* stack_space)
{
    uint32_t* top = stack_space + TASK_STACK_SIZE;

    /* 与硬件中断 + pushad 压栈顺序一致(高地址 -> 低地址) */
    *--top = 0x00000202;            /* EFLAGS,IF=1 */
    *--top = 0x08;                  /* CS */
    *--top = (uint32_t)function;    /* EIP */

    for (int i = 0; i < 8; i++)
        *--top = 0;                 /* pushad 占位 */

    task->esp         = top;
    task->pid         = pid;
    task->priority    = priority;
    task->ticks       = priority;
    task->stack_magic = STACK_MAGIC;
}

void schedule(void)
{
    if (current_task == &task_a)
        current_task = &task_b;
    else
        current_task = &task_a;
}

void c_irq0_handler(void)
{
    if (current_task->ticks > 0) {
        current_task->ticks--;
        return;
    }
    current_task->ticks = current_task->priority;
    schedule();
}

void init_multitasking(void)
{
    task_create(&task_a, task_A, 1, 5, stack_a);
    task_create(&task_b, task_B, 2, 5, stack_b);
    current_task = &task_a;

    /* 重映射后 IRQ0 -> 中断向量 32 */
    register_interrupt_handler(32, (uint32_t)asm_irq0_handler);
    init_8253_raw();
}

3. kernel/task.s

[BITS 32]

global asm_irq0_handler
global init_8253_raw
global switch_to_current_task

extern c_irq0_handler
extern current_task

section .text

; ------------------------------------------------------------------------
; init_8253_raw:控制字 0x36,初值 11932(100Hz)
; IMR 解封留到 switch_to_current_task,避免尚未换栈就响应 IRQ0
; ------------------------------------------------------------------------
init_8253_raw:
    mov al, 0x36
    out 0x43, al

    mov ax, 11932
    out 0x40, al            ; 先低字节
    mov al, ah
    out 0x40, al            ; 后高字节
    ret

; ------------------------------------------------------------------------
; switch_to_current_task:CLI → 解封 IRQ0+IRQ1 → 加载伪造栈并 iret
; ------------------------------------------------------------------------
switch_to_current_task:
    cli

    in  al, 0x21
    and al, 0xFC            ; 开启 bit0(时钟) 与 bit1(键盘)
    out 0x21, al

    mov eax, [current_task]
    mov esp, [eax]
    popad
    iret                    ; 伪造 EFLAGS 中 IF=1,此后开始响应时钟

; ------------------------------------------------------------------------
; asm_irq0_handler:保存旧栈 -> C 调度 -> 加载新栈
; ------------------------------------------------------------------------
asm_irq0_handler:
    pushad

    ; 【必须先保存】此时 current_task 仍是被打断的旧任务
    mov eax, [current_task]
    mov [eax], esp              ; old_task->esp = esp

    call c_irq0_handler         ; 内部可能修改 current_task

    ; 加载(可能已切换的)当前任务栈
    mov eax, [current_task]
    mov esp, [eax]              ; esp = current_task->esp

    mov al, 0x20                ; EOI 紧挨 popad 之前发送
    out 0x20, al

    popad
    iret

4. utilities 新增:通用 IDT 注册

/* utilities.h */
void register_interrupt_handler(uint32_t vector, uint32_t handler);
; utilities.s — cdecl: [esp+4]=vector, [esp+8]=handler
register_interrupt_handler:
    push eax
    push ebx
    mov  ebx, [esp + 12]          ; vector
    mov  eax, [esp + 16]          ; handler
    lea  ebx, [idt + ebx * 8]
    mov  word [ebx + 0], ax       ; offset 15..0
    mov  word [ebx + 2], SELECTOR_CODE
    mov  byte [ebx + 4], 0
    mov  byte [ebx + 5], 0x8E     ; 32 位中断门
    shr  eax, 16
    mov  word [ebx + 6], ax       ; offset 31..16
    pop  ebx
    pop  eax
    ret

5. kernel/kernel.c(演示入口)

#include "utilities.h"
#include "task.h"

void main(void)
{
    clear_screen();
    init_keyboard();   /* 第14章:PIC/IDT/键盘,此时 IRQ0 仍被屏蔽 */

    print_str("Lesson15: PIT + Preemptive Multitasking\r\n", 42);

    init_multitasking();      /* 创建 A/B 任务、注册 IRQ0、启动 8253 */
    switch_to_current_task(); /* 切入 task_A,此后由时钟抢占切换 */
}

成功运行后,屏幕上应交替出现 AB(字符级交错,说明两个任务在抢占式切换)。


六、Debug 验证清单

故障现象 潜在根因 排查动作
刚执行 sti / 切入首任务后无限重启 伪造栈顺序错,或 CS/EFLAGS 写反 核对 task_create:先 EFLAGS,再 CS,再 EIP,最后 8 个 pushad 槽
A 打印一下就卡死 EOI 发得太早,或换栈前未保存旧 esp EOI 必须在换栈之后、popad 之前;先 mov [eax], espcall c_irq0_handler
只跑一个任务,从不切换 换栈顺序写反,新 TCB 的 esp 被覆盖 用 Bochs 在 asm_irq0_handler 单步,确认 current_task 变化前后 esp 读写对象不同
运行一段时间后 #GP 任务栈溢出 增大 TASK_STACK_SIZE;检查 stack_magic 是否仍为 0x55AA55AA

七、本章小结

一句话收束本章机制:

每个任务一块私有栈存放寄存器快照;TCB.esp 记住快照在哪;时钟中断里先把旧 ESP 存进旧 TCB,再把 ESP 换成新 TCB 的值,最后 popad/iret——任务就能在任意指令边界被抢走,并在之后原址复活。

内核由此从静态控制台变成抢占式多任务内核(尚未使用硬件 TSS)。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐