4. 时钟中断初始化:设置中断向量、注册中断处理函数、配置定时器频率、启用中断
好,咱们接着往下走。上一节我们把任务控制块(TCB)和就绪队列搭好了,但光有这些还不够——调度器得有个“心跳”,才能主动去切换任务。这个心跳,就是时钟中断。
说白了,时钟中断就是让CPU每隔固定时间停下来,去执行一段我们写好的代码。这段代码会检查当前任务是不是跑太久了,该不该换人。没有时钟中断,调度器就是个摆设,只能靠任务自己主动让出CPU——那多任务就变成“伪多任务”了。
我个人习惯把时钟中断初始化拆成四个步骤:设置中断向量 → 注册中断处理函数 → 配置定时器频率 → 启用中断。咱们一个一个来。
4.1 设置中断向量
中断向量,说白了就是一张表。CPU收到中断信号后,会去这张表里查:这个中断号对应哪个处理函数?然后跳过去执行。
在x86架构上,这张表叫IDT(Interrupt Descriptor Table)。在ARM Cortex-M上,它叫中断向量表。不管叫什么,本质都一样——一个函数指针数组。
我记得第一次写这个的时候,踩了个坑:IDT的地址必须对齐到8字节边界,否则CPU会直接崩溃。嗯,硬件就是这么死板。
来看一个x86下的简单示例:
// 中断描述符表,256个条目
struct idt_entry {
uint16_t base_low; // 处理函数地址低16位
uint16_t sel; // 代码段选择子
uint8_t always0; // 保留位,必须为0
uint8_t flags; // 类型和属性
uint16_t base_high; // 处理函数地址高16位
} __attribute__((packed));
struct idt_entry idt[256];
// 设置时钟中断(中断号0x20)的向量
void idt_set_gate(uint8_t num, uint32_t base, uint16_t sel, uint8_t flags) {
idt[num].base_low = base & 0xFFFF;
idt[num].base_high = (base >> 16) & 0xFFFF;
idt[num].sel = sel;
idt[num].always0 = 0;
idt[num].flags = flags;
}
// 初始化IDT
void init_idt() {
// 0x20对应时钟中断,0x08是内核代码段,0x8E表示中断门
idt_set_gate(0x20, (uint32_t)timer_handler, 0x08, 0x8E);
// ... 其他中断也类似设置
}
这里有个细节:flags字段的0x8E,二进制是10001110。其中第7位是Present位,必须为1;第5-4位是DPL(描述符特权级),00表示内核态才能触发;第3-0位是类型码,1110表示32位中断门。
⚠️ 我曾经犯过的错: 把DPL设成了3(用户态),结果用户程序也能触发时钟中断。虽然没出大乱子,但想想都后怕——用户程序要是乱改定时器,整个系统就乱套了。所以,时钟中断的DPL一定要设成0。
4.2 注册中断处理函数
向量设好了,接下来得写真正的处理函数。这个函数会在每次时钟中断时被调用。
注意,中断处理函数和普通函数不一样。它运行在中断上下文中,不能睡眠,不能调用可能阻塞的函数,连锁都不能用(除非是中断安全的spinlock)。
我建议把处理函数拆成两部分:
- 上半部(Top Half):快速处理,比如保存寄存器、给定时器芯片发应答信号。
- 下半部(Bottom Half):慢速处理,比如任务调度决策。这部分可以放到软中断或tasklet里执行。
但在微内核里,我们通常把调度逻辑直接放在上半部——因为调度本身要快,而且微内核的调度器本来就简单。
// 时钟中断处理函数(上半部)
void timer_handler() {
// 1. 保存当前任务的寄存器上下文(由CPU自动完成一部分)
// 2. 给PIT(可编程间隔定时器)发应答,告诉它中断已处理
outb(0x20, 0x20); // 发送EOI(End of Interrupt)到主PIC
// 3. 调用调度器,决定下一个要运行的任务
schedule();
// 4. 返回时,CPU会自动恢复新任务的上下文
}
你可能会问:schedule()放在这里,会不会让中断处理时间太长?嗯,这是个好问题。在微内核里,调度器本身很轻量——就是查一下就绪队列,选个优先级最高的任务。如果队列是链表,复杂度O(n);如果用了位图,那就是O(1)。所以,放心放进去。
4.3 配置定时器频率
处理函数写好了,但定时器还没开始工作。我们需要告诉硬件:每隔多少微秒触发一次中断。
在x86上,常用的定时器是PIT(8253/8254芯片)。它有一个计数器,从初始值开始递减,减到0就触发中断。所以,频率 = 输入时钟频率 / 初始计数值。
PIT的输入时钟频率是1.193182 MHz(约1.19 MHz)。如果我们想要100 Hz的中断频率(即每10毫秒一次),计数值就是:
计数值 = 1,193,182 / 100 ≈ 11932
来看代码:
#define PIT_FREQUENCY 1193182 // PIT输入时钟频率
#define TARGET_FREQ 100 // 目标中断频率(Hz)
#define PIT_CHANNEL0 0x40 // 通道0数据端口
#define PIT_COMMAND 0x43 // 命令端口
void init_pit() {
uint16_t divisor = PIT_FREQUENCY / TARGET_FREQ;
// 发送命令:通道0,先写低字节再写高字节,模式3(方波),二进制计数
outb(PIT_COMMAND, 0x36);
// 写入计数值(先低字节,后高字节)
outb(PIT_CHANNEL0, divisor & 0xFF); // 低字节
outb(PIT_CHANNEL0, (divisor >> 8) & 0xFF); // 高字节
}
💡 小技巧: 频率不是越高越好。我见过有人设成1000 Hz(1毫秒一次),结果调度开销太大,系统吞吐量反而下降了。对于通用操作系统,100 Hz是个不错的起点。实时系统可以设到1000 Hz,但要注意上下文切换的开销。
另外,ARM平台通常用SysTick定时器,配置更简单——直接往SYST_RVR寄存器里写重装载值就行。原理是一样的。
4.4 启用中断
最后一步,打开中断总开关。在x86上,这通过sti指令完成:
// 启用中断
__asm__ volatile("sti");
// 如果想同时获取当前状态,可以用:
uint32_t flags;
__asm__ volatile("pushf; pop %0" : "=r"(flags));
if (flags & (1 << 9)) {
// 中断已启用
}
在ARM上,对应的指令是CPSIE I。但要注意,ARM的Cortex-M系列默认中断就是使能的,你只需要配置好NVIC(嵌套向量中断控制器)就行。
嗯,这里有个容易忽略的点:启用中断前,一定要确保所有外设和数据结构都初始化好了。否则,中断一来,处理函数访问到未初始化的变量,系统直接崩溃。
我曾经在调试一个USB驱动时,忘了先初始化USB控制器就开了中断。结果中断处理函数里访问了一个空指针,查了两天才找到原因。从那以后,我养成了一个习惯:中断初始化放在所有硬件初始化的最后一步。
4.5 完整初始化流程
把上面四步串起来,就是时钟中断的完整初始化流程:
- 设置中断向量:在IDT中填入时钟中断的处理函数地址。
- 注册中断处理函数:编写
timer_handler,包含EOI发送和调度调用。 - 配置定时器频率:初始化PIT或SysTick,设置计数值。
- 启用中断:执行
sti或配置NVIC。
用表格总结一下:
| 步骤 | 操作 | 关键寄存器/指令 | 常见错误 |
|---|---|---|---|
| 1 | 设置中断向量 | IDT基址寄存器(IDTR) | IDT未对齐到8字节 |
| 2 | 注册处理函数 | 函数指针 | 处理函数未用__attribute__((interrupt)) |
| 3 | 配置频率 | PIT通道0数据端口 | 计数值计算错误 |
| 4 | 启用中断 | sti / CPSIE I |
中断使能前未初始化外设 |
好了,时钟中断初始化就这些内容。下一节,我们会真正进入调度器的核心——看看schedule()函数里到底发生了什么。