资讯详情 从零编写Linux 0.11中断机制:CPU如何响应外部事件?
📅 2026/10/23 19:16:24
从零编写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映射过来的硬件中断号1280x80被用作系统调用软中断48到255还空着一大把后面要加什么新功能再分配。这个编号跟你平时说的“中断号”IRQ0、IRQ1不是一回事中间差了一个偏移量。8259A的IRQ0对应CPU向量号32IRQ1对应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一起构成属性字节5P存在位、DPL2位、S1位、Type4位字节6到7处理函数偏移地址的高16位一个字一个字拆开看对于中断门来说Type111014对于陷阱门Type111115。P位必须为1DPL是特权级。内核态的中断门DPL0系统调用门DPL3。所以内存里看到的属性值一般是0x8E00内核中断门、0x8F00内核陷阱门、0xEF00系统调用陷阱门。Linux 0.11源码里的set_intr_gate、set_trap_gate、set_system_gate往描述符属性字段里塞的就是这几个值。我不厌其烦地把位布局讲清楚是因为后面手工写_set_gate宏时只要任何一个位放错系统表现都会非常诡异。比如DPL写成了0你去用户态调int 0x80CPU直接判定为特权级违规扔一个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, divide_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把硬件设备的中断请求“接线”给CPU4.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函数写得很清楚我把关键参数列一下参数主片从片含义ICW10x110x11需要ICW4、级联模式ICW20x200x28中断向量起始号主片32、从片40ICW30x040x02级联关系从片挂在主片IRQ2上ICW40x010x018086模式OCW10xFF0xFF默认全部屏蔽逐个开放主片端口是0x20、0x21从片端口是0xA0、0xA1。为什么要让主片从32开始因为0到31是CPU异常的保留区外部中断必须避开。IRQ0对应向量32IRQ7对应39IRQ8对应40IRQ15对应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。DPL3意味着“处在特权级3的用户代码也有权调用这个门”。如果DPL设成0用户程序一执行int 0x80CPU直接判定权限不足触发13号一般保护异常。你可以自己动手改一下DPL试试能非常直观地看到权限检查在起作用。5.2 system_call的入口汇编逻辑在Linux 0.11里0x80向量指向system_call这个汇编函数它的执行分为三步用SAVE_ALL把用户态带来的所有寄存器压入内核栈形成pt_regs结构。检查系统调用号是否越界然后根据eax里的调用号跳到sys_call_table数组对应的C函数。比如eax2就是forkeax4就是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看到当前CPL3而目标代码段的DPL0立即从当前栈切换到ss0:esp0指向的内核栈然后才压入EFLAGS、CS、EIP。整个过程由CPU硬件完成不需要操作系统手动干预。用户态原本栈里的数据并没有被搬走它们还在用户栈上CPU压进去的CS是用户态CSss和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(dpl13)(type8))), \ o (*((char *) (gate_addr))), \ o (*((char *) (gate_addr)4)), \ d ((char *) (addr)), \ a (0x00080000))编译器会把d约束里的addr装进edxa约束里的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是不可屏蔽中断不经过8259ACPU的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。你如果一开始就让IF1系统在IDT半成品状态下一有风吹草动就崩。还有一个小细节Linux 0.11里的sti不是放在init_IRQ之后马上做的它是在调度器第一次schedule之前才做。也就是说内核把“能响应中断的所有条件”准备好之后才把门打开。这个思想特别值得学习不要在任何前置条件未满足时打开中断宁可晚一点开不要冒一点险。9. 写在最后中断让内核活了起来这一章写到这里我对中断机制的看法已经变了不少。以前看操作系统教材总觉得中断是一堆规范和术语的堆砌真正自己从零写完一个能响应中断的内核之后才明白“中断”就是操作系统活起来的“呼吸”。没有中断内核只是一个执行完就停的批处理程序有了中断它才能感知外部世界、管理进程、调度任务。我个人的建议是如果你也在手写操作系统不要只满足于“能把Linux 0.11跑起来”。试着拆掉IDT、拆掉PIC一点一点重新加回来观察每种现象的根源。这个过程比跟着别人的代码读十遍都有用。中断这一章是最容易让人崩溃的但也是最能让人建立起“CPU硬件与软件协作”直觉的地方。接下来写内存分页和进程调度时你会发现到处都是中断和异常在背后撑着——到时候再回头看这一章很多当时觉得晦涩的细节都会变得理所当然。