操作系统学习15 8253 PIT 定时器与多任务(线程)上下文调换
操作系统学习15 8253 PIT 定时器与多任务(线程)上下文切换

在上一章中,我们成功解耦了硬件触发与数据消费,利用环形缓冲区和 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 Hz≈11932=0x2E9C
8253 控制字端口(0x43)与计数端口(0x40)
写入初值前,必须先向 0x43 发送控制字。控制字 0x36(00110110b)含义:
- Bit 7:6 (
00):选择通道 0 - Bit 5:4 (
11):先读写低字节,再读写高字节 - Bit 3:1 (
011):模式 3(方波发生器,适合做时钟嘀嗒) - Bit 0 (
0):二进制计数
二、核心直觉:CPU 其实一次只能跑一个任务
x86 CPU 同一时刻只有一套寄存器:EIP(下一条指令)、ESP(栈顶)、EAX…EDI、EFLAGS(CPU 的32 位标志寄存器) 等。
所谓“两个任务同时跑”,真相是:
每隔约 10ms,把当前任务的整套寄存器“拍照”存进它自己的栈,再换另一套栈顶指针,把另一张照片弹回寄存器——CPU 就以为自己一直在跑同一个程序。
因此必须准备两样东西:
| 对象 | 作用 |
|---|---|
每个任务一块独立内核栈(stack_a / stack_b) |
存放该任务被打断时的寄存器快照;任务私有的局部变量、函数调用帧也在这里 |
任务控制块 TCB(task_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就能直接读写栈顶。
三、栈上到底长什么样?(寄存器快照的内存布局)
保护模式下,时钟中断一旦发生,硬件会自动做三件事(你写的代码还没开始跑):
- 把当前
EFLAGS、CS、EIP依次压入当前正在用的那条栈 - 清掉
EFLAGS.IF(关中断,避免嵌套打乱现场) - 从 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 时间片耗尽,过程对称:
- 硬件把 B 的
EIP_B(B 执行到一半的位置)压进stack_b pushad;task_b.esp = esp(更新 B 的照片,覆盖/接在旧快照逻辑之上)schedule()→current_task = &task_aesp = task_a.esp→ 回到阶段 2 封存的那张 A 的照片popad恢复 A 当年的EAX…EDIiret弹出 当初的EIP_A
于是 CPU 从 i == 12345 的下一条指令接着跑,A 完全不知道中间被抢走过 CPU——局部变量、循环计数、返回地址都还在自己的栈上原封不动。
对照表:各寄存器在切换里扮演什么角色
| 寄存器 / 内存 | 打断时(保存) | 恢复时(加载) | 一句话 |
|---|---|---|---|
| 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 == 0 才 schedule():
pushad+task_a.esp = esp(照样建档)c_irq0_handler只做ticks--,不改current_taskesp = task_a.esp(拿回同一张照片)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,此后由时钟抢占切换 */
}
成功运行后,屏幕上应交替出现 A 与 B(字符级交错,说明两个任务在抢占式切换)。
六、Debug 验证清单
| 故障现象 | 潜在根因 | 排查动作 |
|---|---|---|
刚执行 sti / 切入首任务后无限重启 |
伪造栈顺序错,或 CS/EFLAGS 写反 | 核对 task_create:先 EFLAGS,再 CS,再 EIP,最后 8 个 pushad 槽 |
| A 打印一下就卡死 | EOI 发得太早,或换栈前未保存旧 esp |
EOI 必须在换栈之后、popad 之前;先 mov [eax], esp 再 call 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)。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)