从零编写Linux 0.11中断机制:CPU如何响应外部事件?
从零编写Linux 0.11的系列写到这一章,系统已经能启动、能进保护模式、能打印字符了。但说实话,这时候的内核顶多算个“大号引导程序”——因为CPU还在傻乎乎地按顺序执行指令。外部设备来了数据它不知道,定时器走了它不知道,连程序自己犯了错它也来不及反应。中断机制,就是让这颗CPU从“被动执行”变成“主动响应”的那一锤子。写这一章时我最大的体会是,中断不是操作系统的一个“功能模块”,而是整个内核赖以生存的底层骨架,不理解中断,后面讲进程调度、内存管理都会像在听天书。
先回顾一个最基础的问题:CPU怎么知道外部有事情发生?答案只有两条路,轮询和中断。轮询是CPU每隔一段时间就问一遍设备:“你有事吗?”有事就处理,没事就接着干。这种方式简单直观,但CPU的大部分时间都浪费在“问”上面,而且延迟不确定——设备正好在上一次问完之后来消息,CPU得等到下一次轮询才能知道,运气差一点,延迟能被拉得很长。中断正好反过来:设备主动发一个电信号,CPU在每执行完一条指令后检查一下“有没有中断请求”,有就暂停当前工作,跳转到对应处理程序,处理完再回来。这个“检查”是硬件在指令周期的微操作里完成的,开销极小,而且响应是事件驱动的,来了就处理,不来了CPU安心做自己的事。用人类打个比方:轮询像你每隔十分钟去门口看快递到了没有;中断像给快递员一个门铃,他到了按一下,你再放下手头的事去开门。门铃这个机制,就是8259A和IDT配合实现的。
1.2 中断向量号:从0到255的编号世界
CPU能识别的中断事件不是一个,而是256个,每个都有一个编号,叫中断向量号。这个256不是拍脑袋定的,而是因为IDT最多放256个描述符,每个描述符8字节,总共2KB,正好是页大小的整数倍,查表方便。
其中0到31号向量是CPU保留的异常,由CPU内部触发,包括除零、缺页、非法指令等;32到255号是外部中断和软件中断。在Linux 0.11里,32到47被用作8259A映射过来的硬件中断号,128(0x80)被用作系统调用软中断;48到255还空着一大把,后面要加什么新功能再分配。
这个编号跟你平时说的“中断号”(IRQ0、IRQ1)不是一回事,中间差了一个偏移量。8259A的IRQ0对应CPU向量号32,IRQ1对应33,这就是Linux 0.11里init_IRQ要做的“重新映射”工作。如果你不重新映射,IRQ0默认对应CPU向量8,一进来就会直接撞上Double Fault异常,系统当场崩掉。我当初偷懒跳过PIC初始化,结果模拟器直接重启,查了半天不知道怎么回事。
1.3 保护模式与实模式的分水岭
这一章的标题是“中断与异常”,但如果只看实模式(比如BIOS阶段)的中断,那跟裸机编程没什么区别:中断向量表放在内存地址0x00000开始处,每一项4字节,存的是段地址和偏移,查表跳过去完事。
但进入保护模式之后,一切都变了。实模式的“中断向量表”变成了“中断描述符表”,表的位置不再固定在0x0,而是由IDTR寄存器告诉你它在哪里。每一项不再是一个简简单单的地址,而是一个8字节的门描述符——里面不仅有处理程序的地址,还包含特权级和类型信息。这意味着CPU在跳转之前,还要做一大堆安全检查。很多人第一次写保护模式内核,最容易挂在这一点上:你在实模式下的中断能跑,进了保护模式一开中断就重启,十有八九是IDT没建立或者门描述符的类型/特权级不对。
提示:CPU在保护模式下每执行完一条指令都会检查“当前有没有待处理的中断”。只要中断允许位(IF)是1,并且IDT里对应向量是有效描述符,它就会立刻走中断流程。所以“开中断”这件事不是简单的sti就能完事,你得先保证IDT已经就绪。
2. IDT:一张8字节一行的查找表
2.1 门描述符的位级拆解
IDT里的每一项叫门描述符,一共8字节。以Linux 0.11的启动初始化代码为例,最终要填的是这样一个结构(内存小端序):
- 字节0到1:处理函数偏移地址的低16位
- 字节2到3:段选择符,就是要跳转到的目标代码段,这里固定填0x0008,也就是内核代码段
- 字节4:这个字节比较特殊,一共8位,大部分被用作类型字段的低位,在Linux 0.11的写法里,它和字节5一起构成属性
- 字节5:P(存在位)、DPL(2位)、S(1位)、Type(4位)
- 字节6到7:处理函数偏移地址的高16位
一个字一个字拆开看,对于中断门来说Type=1110(14),对于陷阱门Type=1111(15)。P位必须为1,DPL是特权级。内核态的中断门DPL=0,系统调用门DPL=3。所以内存里看到的属性值一般是0x8E00(内核中断门)、0x8F00(内核陷阱门)、0xEF00(系统调用陷阱门)。Linux 0.11源码里的set_intr_gate、set_trap_gate、set_system_gate,往描述符属性字段里塞的就是这几个值。
我不厌其烦地把位布局讲清楚,是因为后面手工写_set_gate宏时,只要任何一个位放错,系统表现都会非常诡异。比如DPL写成了0,你去用户态调int 0x80,CPU直接判定为特权级违规,扔一个13号异常出来,用户程序根本进不了内核。
2.2 中断门、陷阱门、任务门选哪个?
CPU一共支持三种门。任务门会触发硬件任务切换,Linux 0.11基本没用,我们直接跳过;中断门和陷阱门的区别只有一个细节:进入中断门时CPU会自动把IF标志清0,也就是关中断,防止在处理过程中被新的中断打断;而陷阱门不会清IF。
这个区别决定了它们的用途。硬件中断(比如时钟中断、键盘中断)通常用中断门,因为处理过程是高优先级的,不希望被打扰;异常和系统调用通常用陷阱门,因为进入内核后我们往往要主动管理中断状态,比如缺页异常处理中,你可能希望保持中断开启,让磁盘请求完成时能触发中断来唤醒自己。Linux 0.11里,trap_init用set_trap_gate注册异常,init_IRQ用set_intr_gate注册硬件中断,set_system_gate注册系统调用门,各司其职。
2.3 lidt只做一件事:告诉CPU表在哪
IDT准备好以后,需要执行lidt指令把表的位置和大小加载到IDTR寄存器。IDTR一共6字节,低2字节是表长度减1,高4字节是表的线性地址。在Linux 0.11的head.s里,setup_idt这个汇编函数干的事就是把一整张“空表”铺好:
setup_idt: lea ignore_int, %edx movl $0x00080000, %eax movw %dx, %ax movw $0x8E00, %dx lea idt, %edi mov $256, %ecx rp_sidt: movl %eax, (%edi) movl %edx, 4(%edi) addl $8, %edi dec %ecx jne rp_sidt lidt idt_descr ret这段代码先把256个中断向量统一指到一个ignore_int函数——这个函数暂时什么都不做,只在出错时打一行提示——然后把门类型设成中断门0x8E00,最后加载IDTR。这其实是一种“先把桌子摆好”的启动策略:刚开始来不及逐个设置每个中断的处理函数,就先拿一个统一入口顶住,保证系统不会因为表不存在而崩溃。之后trap_init和init_IRQ再逐个替换成真正的处理函数。
3. 异常:CPU主动扔过来的“错误报告”
3.1 三种异常的语义差异
异常跟外部中断最大的区别是:它不是外部设备发来的,而是CPU在执行指令的过程中自己发现不对劲。比如你写了一条除数为0的指令,CPU在微码层面发现除数是0,就立刻触发0号向量;你访问了一个没有映射的页,就触发14号向量。
异常可以分成三类:Fault(故障)、Trap(陷阱)、Abort(终止)。用一句话解释区别:
- Fault:指令“还没执行完”就发现问题,保存的EIP指向触发异常的那条指令,处理完后CPU会重新执行这条指令。页错误就是典型,异常处理函数把缺的页补上之后,回来还能继续执行。
- Trap:指令“已经执行完”了才触发,保存的EIP指向下一条指令。调试断点int3就是陷阱,处理完直接往下走。
- Abort:发生了严重错误,程序已经无法继续,CPU保存的EIP都不保证正确。Double Fault和机器检查异常属于这一类。
这个区别直接决定异常处理函数返回后CPU去哪。如果你想写一个“屏蔽掉除零错误”的实验型处理程序,你必须想清楚:你站在Fault的立场上,处理完要重新执行那条除法指令,如果除数和被除数没改,你会无限触发这个异常。
3.2 有错误码和无错误码
另一个容易踩的点是:不是所有异常压栈内容都一样。大部分异常压入EFLAGS、CS、EIP之后就跳转到处理函数了;但有少数异常会再压入一个错误码,告诉你是哪个段、哪个页惹的祸。这些异常包括10号无效TSS、11号段不存在、12号栈段异常、13号一般保护异常、14号页异常。
这个错误码是硬件自动压入的,但处理函数返回时,CPU并不会自动把错误码弹出去。所以Linux 0.11在处理这些异常时,特意把入口分成两套:无错误码的,在入口处先压入一个假的错误码,让后续C函数统一按“有一个错误码参数”来处理;有错误码的,直接使用“参数已经由CPU压栈”的入口。
我在写简单实验时,曾直接把有错误码的异常和没错误码的异常当成同一个入口,结果处理函数返回时栈指针对不上,iret弹出的是错误码,程序直接跑飞。这个问题非常隐蔽,你得先把哪些向量带错误码背下来,不然调试到深夜也查不出所以然。
3.3 Linux 0.11的traps.c结构
Linux 0.11的异常处理代码有一个“统一处理+差异化打印”的好设计。在kernel/traps.c里,它给每个异常都注册了一个入口函数,这些入口函数最终都会跳转到一组专门的汇编代码,把现场压栈,然后把“向量号、错误码、当前EIP”作为参数调C函数。
比如初始化代码:
void trap_init(void) { set_trap_gate(0, ÷_error); set_trap_gate(1, &debug); set_trap_gate(2, &nmi); set_trap_gate(3, &int3); set_trap_gate(4, &overflow); set_trap_gate(5, &bounds); set_trap_gate(6, &invalid_op); set_trap_gate(7, &device_not_available); set_trap_gate(8, &double_fault); set_trap_gate(9, &coprocessor_segment_overrun); set_trap_gate(10, &invalid_TSS); set_trap_gate(11, &segment_not_present); set_trap_gate(12, &stack_segment); set_trap_gate(13, &general_protection); set_trap_gate(14, &page_fault); set_trap_gate(15, &reserved); set_trap_gate(16, &coprocessor_error); }这里的第二个参数是“入口函数的地址”,但这个入口函数不是普通的C函数,而是一段汇编。为什么不能用C函数直接当中断入口?因为C编译器生成的函数开头会有标准的栈布局和现场保存动作,但它不会替你把EFLAGS、CS这些由硬件压栈的值按预期顺序弹出来,更不会保证处理完后用iret返回。中断入口必须严格遵循“硬件压栈顺序”和“iret弹出顺序”,所以必须有汇编包一层。这是初写中断时最大的思维转换:中断处理函数的“调用约定”不是操作系统约定的,是CPU硬件约定的。你没办法让编译器替你遵守这个约定,只能手写。
4. 8259A:把硬件设备的中断请求“接线”给CPU
4.1 主片从片为什么是级联的
8259A是可编程中断控制器,俗称PIC。在PC/AT架构里,两块8259A级联,主片管IRQ0到IRQ7,从片管IRQ8到IRQ15,从片的中断输出接到主片的IRQ2上。所以主片IRQ2这条线承载的是“从片有中断”的信号。
这么设计的根本原因很朴实:老CPU只有一根INT引脚,硬件上无法直接接16个设备,只能通过一个控制器汇总。8259A内部有一个优先级裁决器,多设备同时请求时,按优先级挑一个报给CPU。你要是不初始化8259A,它内部状态完全是乱的,中断请求的映射和屏蔽位全不对,设备一触发就觉得“有扇门倒了”,系统立刻崩。
4.2 初始化参数:ICW与OCW的计算
Linux 0.11的init_8259A函数写得很清楚,我把关键参数列一下:
| 参数 | 主片 | 从片 | 含义 |
|---|---|---|---|
| ICW1 | 0x11 | 0x11 | 需要ICW4、级联模式 |
| ICW2 | 0x20 | 0x28 | 中断向量起始号:主片32、从片40 |
| ICW3 | 0x04 | 0x02 | 级联关系:从片挂在主片IRQ2上 |
| ICW4 | 0x01 | 0x01 | 8086模式 |
| OCW1 | 0xFF | 0xFF | 默认全部屏蔽,逐个开放 |
主片端口是0x20、0x21,从片端口是0xA0、0xA1。为什么要让主片从32开始?因为0到31是CPU异常的保留区,外部中断必须避开。IRQ0对应向量32,IRQ7对应39,IRQ8对应40,IRQ15对应47,后面接设备时看到0x20、0x21这些宏就应该条件反射地想到“这是PIC的口地址,不是内存地址”。
4.3 时钟中断是怎么“开”起来的
时钟中断是整个操作系统的脉搏。Linux 0.11在init_IRQ里除了初始化8259A,还做了两件事:注册IRQ0的handler,以及操作OCW1把IRQ0对应的中断释放出来。比如主片掩码改成0xFE,就是把IRQ0开放,其他保持屏蔽。
注意这里有一个容易混的概念:CPU的IF标志是“总闸”,PIC的OCW1是“分闸”,一个设备要真正触发中断,总闸和分闸都得打开。我在实验里只开了IF,但忘了改OCW1,结果时钟中断永远不来,系统像死了一样,查了很久才意识到PIC侧还被屏蔽着。
5. 系统调用:让用户程序主动“敲门”进内核
5.1 int 0x80:一条特殊指令
系统调用是中断机制最巧妙的用途。用户态的程序想访问硬件、申请内存、打开文件,直接执行特权指令是会被CPU拒之门外的,因为当前特权级是3,而特权指令要求特权级0。那怎么办?答案是:用户程序执行一条int 0x80指令,主动触发一个软件中断。
关键点是,0x80这个系统调用门描述符的DPL被设成了3。DPL=3意味着“处在特权级3的用户代码也有权调用这个门”。如果DPL设成0,用户程序一执行int 0x80,CPU直接判定权限不足,触发13号一般保护异常。你可以自己动手改一下DPL试试,能非常直观地看到权限检查在起作用。
5.2 system_call的入口汇编逻辑
在Linux 0.11里,0x80向量指向system_call这个汇编函数,它的执行分为三步:
- 用SAVE_ALL把用户态带来的所有寄存器压入内核栈,形成pt_regs结构。
- 检查系统调用号是否越界,然后根据eax里的调用号跳到sys_call_table数组对应的C函数。比如eax=2就是fork,eax=4就是write。这里有一个很漂亮的细节:sys_call_table是个函数指针数组,数组下标就是调用号,所以“根据调用号分发”就是一次“数组索引+间接跳转”。
- 拿到返回值后,把返回值写回内核栈中原来eax保存的位置,这样iret恢复时eax自然就是返回值,然后用RESTORE_ALL弹出寄存器,执行iret回到用户态。
很多人在理解系统调用时,会把它当成普通的“函数调用”,但它的本质是中断调用:用户程序不压返回地址,CPU压的是EFLAGS、CS、EIP;用户程序不保证栈方向,CPU要切换到内核栈。所以你写系统调用时,不能写“返回值在eax里”就直接return,必须先把现场完整压栈,因为中断的返回不是ret,是iret。
5.3 从用户态到内核态的栈切换
还有一个绕不开的细节:特权级变了,栈也要变。Linux 0.11为每个任务都准备了一个内核栈,TSS里有ss0和esp0两个字段记录内核栈的段和栈顶。当用户态程序触发int 0x80时,CPU看到当前CPL=3而目标代码段的DPL=0,立即从当前栈切换到ss0:esp0指向的内核栈,然后才压入EFLAGS、CS、EIP。整个过程由CPU硬件完成,不需要操作系统手动干预。
用户态原本栈里的数据并没有被搬走,它们还在用户栈上,CPU压进去的CS是用户态CS,ss和esp保存在TSS里,处理完通过iret弹回。到内核态之后想访问用户栈里的参数,就需要通过用户空间指针去读,这也是为什么Linux 0.11里很多系统调用函数要从“用户栈上取参数”这一步开始。
6. 代码落地:从零初始化IDT的完整记录
6.1 分阶段:先setup再trap_init再init_IRQ
我在实际搭建内核的过程中,中断相关的初始化是分三个时间点进行的,这个顺序是Linux 0.11源码给我的启发,也强烈推荐大家遵循同样的节奏:
第一阶段,在head.s里setup_idt,把256个向量全部指向ignore_int。这一步的目的是先把表建起来,但先不处理任何中断事件。
第二阶段,在main的trap_init里,把0到16的CPU异常逐个替换成真正的处理入口。
第三阶段,在init_IRQ里,初始化8259A,把硬件中断向量注册好,并打开需要的IRQ屏蔽位。
这个顺序不能乱。如果上来就让硬件中断开着,但IDT里没有对应描述符,CPU查表失败直接引发Double Fault。先让PIC全部屏蔽,再逐个开放,就能把“不知道该谁处理的中断”扼杀在萌芽里。
6.2 _set_gate宏逐行拆解
_set_gate是Linux 0.11里最精华的宏之一,很多人第一次看到它的内联汇编会头痛。我把它逐行翻译一下:
#define _set_gate(gate_addr, type, dpl, addr) \ __asm__("movw %%dx,%%ax\n\t" \ "movw %0,%%dx\n\t" \ "movl %%eax,%1\n\t" \ "movl %%edx,%2\n\t" \ : \ : "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \ "o" (*((char *) (gate_addr))), \ "o" (*((char *) (gate_addr)+4)), \ "d" ((char *) (addr)), \ "a" (0x00080000))编译器会把"d"约束里的addr装进edx,"a"约束里的0x00080000装进eax。movw %dx, %ax把addr低16位搬到ax,这时候eax就变成了0x0008xxxx——前16位是段选择符0x0008,后16位是处理函数地址的低16位。接着movw %0, %dx把属性(比如0x8E00)装进dx,此时edx的高16位仍然是addr的高16位,低16位是属性。最后两条movl把edx、eax分别写入门描述符的第0字节和第4字节。一个8字节的门描述符就填好了。
写这个宏的时候,我建议初学的人不要光看宏,一定要把门描述符的8字节按“偏移低16位/段选择符/属性/偏移高16位”画个图,对着填充一遍。我当年在纸上画了不下十遍,才真正记住每个bit在哪。
6.3 trap_init与init_IRQ的挂接细节
trap_init的代码前面已经展示过了,它调set_trap_gate把0到16的异常入口都设好。这里有个容易被忽略的点:2号向量(NMI)也注册了。NMI是不可屏蔽中断,不经过8259A,CPU的NMI引脚一被拉低就直接触发2号向量。Linux 0.11对NMI的处理很佛系:打印一条错误消息,然后进死循环。因为NMI本身意味着硬件层面严重异常,普通内核一般不会去“处理”它。
init_IRQ做的事是:先初始化PIC,然后把硬件中断向量注册到IDT。注册时要先把门描述符填好,再在PIC里开放对应IRQ,顺序也是先注册后开放。我实验时试过反着来,结果中断先到了,处理函数还没挂上,系统启动到一半就崩了。这些顺序问题在教科书里不会直接告诉你,只有跑一遍才知道。
7. 实战复现:用一个小demo验证中断基础
7.1 先搭一个最小可运行的中断实验环境
如果你也正在跟着“从零编写Linux 0.11”这一系列走,我会建议你写到这一章时不要急着一口气调完整内核,而是先做一个独立小实验:只保留最精简的启动代码、建立IDT、初始化8259A,用一个自定义门来模拟一个“中断测试”,处理函数里设置一个标志变量并打印一段字符串。如果实验能在模拟器里跑出预期的打印结果,说明IDT、门描述符、特权级链路已经通了。
直接构造一个最小的测试入口:
void test_int_entry(void) { printk("[test] interrupt handler called!\n"); } void test_setup(void) { set_intr_gate(0x40, &test_int_entry); __asm__("int $0x40"); }如果屏幕上打出了test消息,就说明IDT设置、门描述符类型、段选择符、特权级检查全都没问题——这几个环节只要有一步出错,轻则没反应,重则整机重启。这个小demo比在一整个复杂内核里排查问题要快得多。
7.2 用int指令主动触发验证
玩过汇编的人都知道,int指令后面跟的是向量号,int 0x40这种写法在执行时,CPU查的是IDT的第0x40项。所以在上述测试里,只要把set_intr_gate(0x40, ...)设好,然后执行int 0x40,就能直接跳过外部设备,纯靠软件触发一次完整的中断流程。
这一步的价值在于:如果连软件触发的内部中断都进不去处理函数,那问题一定出在IDT、门描述符或特权级这部分;如果软件触发一切正常,但外部设备的中断没反应,那问题就出在PIC初始化或设备本身。这相当于给整个中断链路做了“单元测试”,排查范围一下子缩小了。我后来调试键盘驱动时,就是靠这个把IDT的问题和PIC的问题区分开的。
8. 调试心得与避坑清单
8.1 Triple fault是怎么发生的
刚连上模拟器跑内核时,遇到最多的一个现象是:启动到某一步,屏幕上最后一行打印消失了,或者模拟器直接黑屏,甚至整个模拟机自己重启。这个现象叫Triple Fault——CPU在处理第一个异常时发现IDT无效,触发Double Fault;处理Double Fault时IDT还是无效,那就无药可救,硬件直接复位重启。它连显示错误的机会都没有。
最常见的原因有这几个:
- lidt指令还没执行,就开了中断;
- IDT放在了一个不可访问的内存区域,或者IDTR的基地址算错了;
- IDT描述符里的段选择符指向的段不存在,或者特权级不匹配;
- 给8259A的初始化命令顺序搞错,导致IRQ向量号错乱。
排查Triple fault的办法是:给模拟器打开中断日志,让它把每次中断/异常的详细信息写进日志文件,CPU当前在哪个EIP、触发哪个向量、IDT表项内容是什么都一清二楚。我第一次看到日志里满屏的“check_exception: 8”时,才意识到是IDT表项全空引起的兜底异常。
8.2 8259A的坑:中断全被屏蔽了?
另一个特别容易忽略的问题:init_8259A之后,OCW1发0xFF把所有中断屏蔽了,外部硬件中断根本到不了CPU。这时候你需要打开对应位。我当时测试键盘中断,发现怎么敲都没反应,查了很久才发现IRQ1一直是masked状态。所以“要不要开CPU中断”和“PIC里要不要开某个IRQ”是两件事,前者是CPU级IF标志,后者是PIC级屏蔽寄存器,少哪一个都不行。
8.3 中断打开后系统正常启动的顺序
我们经常说要“先注册后开放”,其实开CPU级中断的时间点是最后一个阶段。head.s刚进保护模式时,清IF是必须的,因为此时IDT还没建好。trap_init、init_IRQ都完成之后,才在main里用sti打开IF。你如果一开始就让IF=1,系统在IDT半成品状态下一有风吹草动就崩。
还有一个小细节:Linux 0.11里的sti不是放在init_IRQ之后马上做的,它是在调度器第一次schedule之前才做。也就是说,内核把“能响应中断的所有条件”准备好之后,才把门打开。这个思想特别值得学习:不要在任何前置条件未满足时打开中断,宁可晚一点开,不要冒一点险。
9. 写在最后:中断让内核活了起来
这一章写到这里,我对中断机制的看法已经变了不少。以前看操作系统教材,总觉得中断是一堆规范和术语的堆砌,真正自己从零写完一个能响应中断的内核之后,才明白“中断”就是操作系统活起来的“呼吸”。没有中断,内核只是一个执行完就停的批处理程序;有了中断,它才能感知外部世界、管理进程、调度任务。
我个人的建议是,如果你也在手写操作系统,不要只满足于“能把Linux 0.11跑起来”。试着拆掉IDT、拆掉PIC,一点一点重新加回来,观察每种现象的根源。这个过程比跟着别人的代码读十遍都有用。中断这一章是最容易让人崩溃的,但也是最能让人建立起“CPU硬件与软件协作”直觉的地方。接下来写内存分页和进程调度时,你会发现到处都是中断和异常在背后撑着——到时候再回头看这一章,很多当时觉得晦涩的细节,都会变得理所当然。