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 完整初始化流程

把上面四步串起来,就是时钟中断的完整初始化流程:

  1. 设置中断向量:在IDT中填入时钟中断的处理函数地址。
  2. 注册中断处理函数:编写timer_handler,包含EOI发送和调度调用。
  3. 配置定时器频率:初始化PIT或SysTick,设置计数值。
  4. 启用中断:执行sti或配置NVIC。

用表格总结一下:

步骤 操作 关键寄存器/指令 常见错误
1 设置中断向量 IDT基址寄存器(IDTR) IDT未对齐到8字节
2 注册处理函数 函数指针 处理函数未用__attribute__((interrupt))
3 配置频率 PIT通道0数据端口 计数值计算错误
4 启用中断 sti / CPSIE I 中断使能前未初始化外设

好了,时钟中断初始化就这些内容。下一节,我们会真正进入调度器的核心——看看schedule()函数里到底发生了什么。