用Python模拟CPU中断机制:从断点保护到中断返回的完整实现
中断机制是计算机组成原理里最绕的一块没有之一。我在带实验课的时候见过太多同学对着实验箱上的 8259A 芯片发呆明明原理课上都懂一旦要自己接线、编程、观察中断过程立刻大脑短路。后来我干脆自己写了个 Python 模拟程序把 CPU 的内部状态全部打印出来一步一步看 PC 怎么跳转、断点怎么保存、中断怎么返回所有学生看完都说“原来如此”。这篇文章就是把这套模拟程序完整地整理出来包含可以直接运行的全部代码不需要硬件不需要额外环境装好 Python 就能跑。这个模拟程序的核心价值在于它把中断机制的五个关键环节——中断请求、中断响应、断点保护、中断处理、中断返回——全部还原成可以一步步观察、一行行打印的状态变化。适合正在学计算机组成原理的实验党、准备复试的考研党以及所有想知道“中断到底是怎么在 CPU 内部执行的”的人。跑通这个模拟器之后再回头看真实的 NVIC 或者 8259A你会发现思路顺了很多。1. 实验思路拆解先搞懂中断机制在做什么1.1 中断机制的核心流程很多人学中断的时候被各种术语砸晕中断源、中断屏蔽、中断向量、中断嵌套……但说到底中断机制干的事情就一句话CPU 正在执行主程序突然来了一个紧急事件CPU 放下手头的事跳过去处理紧急事件处理完了再回来接着原来的地方继续干。这句话里藏着四个不可省略的步骤中断请求某个外设或者内部定时器向 CPU 发出“我需要被处理”的信号。中断响应CPU 在每一条指令执行完之后检查这个信号如果满足条件就进入响应流程。断点保护因为 CPU 现在要跳走它必须记住自己刚才执行到哪条指令也就是把当前 PC 的值保存下来。否则处理完紧急事件它找不回来原来的路。中断处理与返回跳转到中断服务程序ISR执行紧急任务结束后把保存的 PC 弹出来恢复到原来的执行位置。如果类比现实生活就像你正在写一份报告突然电话响了。你停下写报告的动作记住自己写到第几段接电话处理事情挂了电话回来从刚才的段落继续写。记住“写到第几段”就是断点保护接电话就是中断处理。理解这个流程之后你会发现中断机制的难点根本不在“概念”上而在“CPU 到底在哪个精确的时机做判断”“跳转的时候内部寄存器是怎么变化的”。这些细节在真实硬件上没法直接观察而用模拟程序就太方便了。1.2 为什么用模拟程序来做这个实验我在实验课上的体会是硬件实验箱存在三个让人崩溃的问题。第一内部状态不可见。你按下中断按钮LED 亮了程序跳过去了但你根本看不到 PC 的值是怎么保存到栈里的。对它来说中断响应只是一个黑盒。第二时钟太快。CPU 主频哪怕只有几兆赫兹人眼也完全跟不上指令级别的事件。就算接了示波器观察的也是引脚电平而不是 PC、栈这些程序执行层面的状态。第三硬件实验环境不稳定。接线松动、芯片损坏、跳线帽位置错都可能让学生花大量时间在排障上反而没精力理解核心原理。而模拟程序天然没有这些问题。你可以在每一条指令执行后打印 CPU 寄存器的快照可以在中断响应的瞬间把栈内容完整展示出来可以放慢速度看每一次跳转。这些都是真实硬件极难做到的。我设计的这个模拟器目标非常明确模拟一个微型 CPU执行一段主程序有一个定时器型的中断源每执行若干条指令产生一次中断请求CPU 在每条指令执行完毕后检查中断请求满足条件就响应中断跳转执行 ISRISR 执行完通过特定指令返回主程序继续原来的流程整个模拟过程用打印信息完整记录让使用者清楚看到每个时刻的 PC、寄存器、栈和中断标志位。1.3 模拟程序整体架构划分在设计模拟程序之前我先把它拆成几个互相独立又有关联的模块这对应着真实 CPU 的部件划分CPU 核心包含程序计数器 PC、指令寄存器 IR、通用寄存器 A也可以再加 B、栈指针 SP。这个模块负责取指、译码、执行。内存与指令存储用列表存储指令序列用另一个列表模拟一块独立的数据内存。真实 CPU 的指令和数据在同一个地址空间对于这个实验可以直接把指令列表当成只读代码段。中断控制器负责产生中断请求、维护中断请求标志、维护全局中断开关以及保存中断向量表。这个模块是中断机制的“中转站”。主程序与中断服务程序两段分别存放在不同地址区的指令序列。主程序模拟一个普通的计算循环ISR 模拟紧急处理任务并且严格按照“保存现场 → 处理 → 恢复现场 → 返回”的规范编写。这个拆分方式很重要。因为中断机制本身就是多个部件协作的结果如果你把所有逻辑写在一个大 while 循环里代码倒是省事了但读代码的人完全感受不到“CPU”“中断控制器”“栈”这三个角色各自的职责。分开之后每个模块的职责一目了然也方便后续扩展中断嵌套、多中断源等功能。2. 核心模块设计与关键代码解析2.1 CPU 寄存器、内存和栈的设计先看 CPU 核心的基本结构。我用了最简的寄存器集合一个 PC、一个指令寄存器 IR、一个通用寄存器 A、一个栈指针 SP。真实 CPU 的寄存器比这多得多但对于演示中断机制来说这些已经足够。内存这边我用两个 Python 列表来模拟。第一个列表 code 存放指令第二个列表 data 模拟数据内存初始长度设为 128元素全部是 0。栈也单独用一个列表实现栈顶用 SP 来指示。之所以不用 Python 自带的 list.append 和 list.pop 直接模拟栈是为了更贴近真实 CPU 栈机制——压栈和弹栈都是通过 SP 变化管理内存空间这个细节有教学价值。关键代码如下class CPU: def __init__(self, code, vector_table): self.PC 0 # 程序计数器 self.IR 0 # 指令寄存器 self.A 0 # 通用寄存器 A self.SP 0 # 栈指针0 表示空栈 self.running True # 运行状态 self.code code # 指令内存 self.data [0] * 128 # 数据内存 self.stack [0] * 32 # 栈空间 self.vector_table vector_table # 中断向量表 # 中断控制相关 self.int_flag False # 中断请求标志 self.ie_flag True # 全局中断使能True 表示开放 self.in_isr False # 当前是否正在执行 ISR self.timer 0 # 定时器计数器 self.threshold 3 # 每执行 3 条指令触发一次中断请求注意看这里的int_flag和ie_flag。这两个标志位是中断机制的灵魂分别对应真实 CPU 中的“中断请求锁存器”和“全局中断允许标志”。很多初学者会把它们弄混我后面会专门讲这两个标志的坑。栈的初始 SP 为 0我实现的压栈逻辑是先把 SP 加 1再把值写到 stack[SP]弹栈则是取出 stack[SP]再让 SP 减 1。这样符合大多数人习惯的“栈从低地址向高地址增长”的约定观察时也更直观。2.2 指令集与执行流程为了让代码足够简单我定义了一套极简指令集每条指令用元组表示第一个元素是操作码后面是操作数。指令集如下表所示指令格式含义LDALDA imm将立即数 imm 加载到寄存器 AADDADD imm将立即数 imm 加到寄存器 ASTASTA addr将寄存器 A 的值存入数据内存 addrLODLOD addr将数据内存 addr 的值加载到寄存器 AOUTAOUTA打印当前寄存器 A 的值JMPJMP addr无条件跳转到 addrHALTHALT停机RETIRETI中断服务程序返回其中RETI是专门为中断机制设计的指令普通主程序里不会出现。执行这条指令时CPU 要做的不仅仅是弹栈恢复 PC还要恢复中断使能状态、清除in_isr标志。这一点我会在 2.4 节详细展开。执行流程的主循环是典型的“取指—译码—执行”三步走def step(self): self.IR self.code[self.PC] # 取指 print(f[取指] PC{self.PC}, 指令{self.IR}, end ) self.PC 1 # 默认 PC 指向下一条指令 self.execute(self.IR) # 执行 self.timer 1 # 定时器计数 if self.timer self.threshold: self.int_flag True self.timer 0 print(f 定时器触发: 中断请求标志置为 True) # 指令执行完毕后检查中断 if self.int_flag and self.ie_flag and not self.in_isr: self.response_interrupt()这里面最容易忽略的细节是中断检查要放在一条指令完整执行完之后而不是放到取指之前更不能放到执行之前。真实 CPU 的中断响应也遵循这个原则CPU 必须保证当前指令执行完才能被中断打断。否则程序的状态会处于“指令执行到一半”的中间态无法恢复。模拟器中我特意把中断检查放在 execute 之后、下一次取指之前就是在传递这个设计原则。2.3 中断控制器与中断向量表中断控制器在真实硬件里是一块独立的芯片或者 CPU 内部模块在模拟器里我用 CPU 类内部的一组状态和一张中断向量表来表示。中断源我选择最简单的定时器CPU 每执行一条指令timer 加 1当 timer 达到 threshold这里设成 3就把int_flag置为 True表示有中断请求挂起。这相当于真实系统里定时器溢出后向 CPU 发出中断请求线的电平信号。中断向量表用字典实现键是中断号值是对应的 ISR 入口地址vector_table { 0: 10, # 中断号 0 对应的服务程序入口地址是 10 }中断响应函数是重中之重def response_interrupt(self): print(f\n 响应中断 ) print(f 断点保护: 将当前 PC{self.PC} 压入栈) self.stack_push(self.PC) # 保存断点 self.PC self.vector_table[0] # 跳转到中断服务程序入口 self.in_isr True # 标记正在执行 ISR self.ie_flag False # 关中断禁止新的中断打断 self.int_flag False # 清除中断请求标志 print(f 跳转到中断服务程序入口: PC{self.PC})这里每一步都有讲究。为什么要把当前 PC 压栈因为在主循环中我执行self.PC 1之后才执行指令所以当指令执行完毕时PC 已经指向了指令序列的下一条指令。也就是说PC 里保存的正是主程序中断返回后要执行的下一条指令地址。这个地址叫“断点”必须原样保存RETI 时才能准确返回。压栈保存而不是保存在固定寄存器里是因为中断还可能嵌套——如果来了优先级更高的中断CPU 需要再次保存断点而栈这种 LIFO 结构天然支持重复嵌套。关中断ie_flag False也很关键。它保证在 ISR 执行期间不会再来一个中断打断当前的中断处理过程。如果想模拟中断嵌套可以在这里不关闭中断但那是进阶玩法后面我会说。2.4 中断服务程序与现场保护有了响应逻辑还要有 ISR 本身。我按照规范的中断处理流程来编写 ISR保存现场 → 处理事务 → 恢复现场 → 返回。所谓保存现场就是把中断处理过程中可能要修改的寄存器这里是 A先保存到数据内存里。处理完后再从内存恢复这样返回主程序后主程序的运行环境不被破坏。ISR 的指令序列放在地址 10 开始# 中断服务程序入口地址 10 (10, (STA, 100)), # 保存现场: 将 A 的值存入数据内存 100 号单元 (11, (LDA, 888)), # 模拟紧急处理: 把 A 改为 888 (12, (OUTA,)), # 打印 ISR 处理过程的值 (13, (LOD, 100)), # 恢复现场: 将保存的 A 值取回 (14, (RETI,)), # 中断返回RETI的实现是elif op RETI: self.PC self.stack_pop() # 恢复断点 self.in_isr False # 退出中断服务状态 self.ie_flag True # 重新开放中断 print(f 中断返回: 恢复 PC{self.PC}, 重新开放中断)执行完 RETI 后主循环检查in_isr已经变成 False、ie_flag变成 True主程序就能继续正常执行了。如果定时器又达到阈值会再次触发中断形成完整的“循环触发—响应—返回”过程观察起来非常清晰。3. 完整代码实现与运行效果说明3.1 完整代码下面是完整的模拟程序代码。我把它写得尽量详细注释也标得比较清楚直接复制保存成interrupt_sim.py就能运行。 计算机组成原理实验用模拟程序实现中断机制 运行环境Python 3.6 运行方式python interrupt_sim.py # 定义简单的 CPU 模拟器 class CPU: def __init__(self, code, vector_table): self.PC 0 # 程序计数器 self.IR 0 # 指令寄存器 self.A 0 # 通用寄存器 A self.SP 0 # 栈指针0 表示空栈 self.running True # 运行状态 self.code code # 指令内存 self.data [0] * 128 # 数据内存 self.stack [0] * 32 # 栈空间 self.vector_table vector_table # 中断向量表 # 中断控制相关 self.int_flag False # 中断请求标志 self.ie_flag True # 全局中断使能 self.in_isr False # 是否正在执行 ISR self.timer 0 # 定时器计数 self.threshold 3 # 每执行 3 条指令触发一次中断请求 def stack_push(self, value): self.SP 1 self.stack[self.SP] value def stack_pop(self): value self.stack[self.SP] self.SP - 1 return value def execute(self, instr): op instr[0] if op LDA: self.A instr[1] print(f执行 LDA {instr[1]}, A{self.A}) elif op ADD: self.A instr[1] print(f执行 ADD {instr[1]}, A{self.A}) elif op STA: self.data[instr[1]] self.A print(f执行 STA {instr[1]}, 内存[{instr[1]}] {self.A}) elif op LOD: self.A self.data[instr[1]] print(f执行 LOD {instr[1]}, A{self.A}) elif op OUTA: print(f执行 OUTA, 输出 A {self.A}) elif op JMP: self.PC instr[1] print(f执行 JMP {instr[1]}, PC{self.PC}) elif op HALT: self.running False print(执行 HALT, 程序停机) elif op RETI: self.PC self.stack_pop() self.in_isr False self.ie_flag True print(f执行 RETI, 恢复 PC{self.PC}, 重新开放中断) else: raise ValueError(f未知指令: {op}) def response_interrupt(self): print(f\n 响应中断 ) print(f 断点保护: 将当前 PC{self.PC} 压入栈) self.stack_push(self.PC) self.PC self.vector_table[0] self.in_isr True self.ie_flag False self.int_flag False print(f 跳转到中断服务程序入口: PC{self.PC}) def step(self): self.IR self.code[self.PC] print(f[取指] PC{self.PC}, 指令{self.IR}, end ) self.PC 1 self.execute(self.IR) # 定时器计数并产生中断请求 self.timer 1 if self.timer self.threshold: self.int_flag True self.timer 0 print(f 定时器触发: 中断请求标志置为 True) # 指令执行完毕后检查中断 if self.int_flag and self.ie_flag and not self.in_isr: self.response_interrupt() def run(self, max_steps100): count 0 while self.running and count max_steps: self.step() count 1 print(f\n模拟结束共执行 {count} 条指令) def main(): # 主程序 # 地址 0: 初始化 A1 # 地址 1~4: 循环输出 A 并累加 code { 0: (LDA, 1), 1: (OUTA,), 2: (ADD, 1), 3: (OUTA,), 4: (JMP, 1), } # 中断服务程序入口地址为 10 isr { 10: (STA, 100), # 保存现场 11: (LDA, 888), # 模拟紧急处理 12: (OUTA,), # 输出处理过程 13: (LOD, 100), # 恢复现场 14: (RETI,), # 中断返回 } # 合并指令区 full_code {**code, **isr} # 中断向量表中断号 0 指向 ISR 入口地址 10 vector_table {0: 10} cpu CPU(full_code, vector_table) cpu.run(max_steps50) if __name__ __main__: main()3.2 运行方式与预期输出运行很简单在终端里执行python interrupt_sim.py程序会开始执行主程序并在每执行 3 条指令后触发一次中断请求。输出内容大致是下面这样的节奏[取指] PC0, 指令(LDA, 1) 执行 LDA 1, A1 定时器触发: 中断请求标志置为 True [取指] PC1, 指令(OUTA,) 执行 OUTA, 输出 A 1 [取指] PC2, 指令(ADD, 1) 执行 ADD 1, A2 ... 响应中断 断点保护: 将当前 PC3 压入栈 跳转到中断服务程序入口: PC10 [取指] PC10, 指令(STA, 100) 执行 STA 100, 内存[100] 2 [取指] PC11, 指令(LDA, 888) 执行 LDA 888, A888 [取指] PC12, 指令(OUTA,) 执行 OUTA, 输出 A 888 [取指] PC13, 指令(LOD, 100) 执行 LOD 100, A2 [取指] PC14, 指令(RETI,) 执行 RETI, 恢复 PC3, 重新开放中断 [取指] PC3, 指令(OUTA,) 执行 OUTA, 输出 A 3 ...注意看输出的几个关键点中断响应时PC 从 2 变成了 3因为 ADD 指令已经执行完PC 自增指向下一条指令这个 3 被压栈保存。ISR 执行期间A 被改成了 888OUTA 输出 888随后通过 LOD 100 恢复成进入中断前的值 2。RETI 之后PC 恢复为 3主程序继续执行 OUTA输出 A3。这个输出顺序完整演示了“保存现场 → 处理 → 恢复现场 → 返回”的整个流程。建议读者自己跑一遍观察真实的打印信息比看这篇博文的文字描述深刻得多。3.3 关键代码段注释与参数调整玩法如果你想实验不同的中断触发频率只需要改一个地方self.threshold 3把它改成 1会变成每条指令执行完都产生中断请求改成 10则每 10 条指令才触发一次。我建议先设成 3 或者 4这样既能看到主程序的正常执行过程又能频繁看到中断现象。还可以修改 ISR 里的处理动作。比如把LDA 888改成其他数值或者在 ISR 里多加几条 OUTA 指令观察更长的事务处理流程。甚至可以把LDA 888去掉直接在 ISR 里执行 HALT你会看到主程序再也没法恢复——这就是没有正确处理中断返回的后果。我再补充一个调试小技巧如果你觉得输出太多看不过来可以把step方法里的打印拆成两个级别。把定时器和中断响应的关键信息保留把普通指令的执行信息简化。我在代码里为了教学故意全部打印实际自己研究时可以按需裁剪。4. 常见问题与排查技巧实录4.1 常见问题速查表我在让不同层次的同学跑这份模拟代码时总结出一批高频问题。这里整理成速查表方便读者对照排查问题现象问题原因解决办法终端运行后没有任何中断响应ie_flag初始为 False或者程序执行 HALT 提前结束确认全局中断使能确认主程序里没有 HALT中断响应后主程序从错误的地址恢复RETI 弹栈恢复了错误的 PC 值检查断点保护时压栈的 PC 是否正确检查栈的压弹顺序是否一致中断服务程序执行完一次后主程序立刻又被中断看不到主程序执行中断请求标志在响应时没有被清除确认在response_interrupt中执行了self.int_flag False执行 RETI 后程序一直卡在 ISR 内部ISR 内部没有正常命中 RETI而是跳到了别处检查 JMP 指令的目标地址看 ISR 里是否不小心修改了 PC中断处理过程中 A 的值改动影响了主程序ISR 里修改了 A 但没有恢复在 ISR 开头保存现场在 RETI 前恢复现场栈越界SP 超出栈空间多次中断嵌套或者压栈过多增加栈空间数组长度或检查是否存在递归中断这里面最隐蔽、最容易犯的错误就是第三行中断请求标志的清除问题。很多新手在响应中断时只跳转到 ISR、保存了断点却忘了把int_flag复位结果 RETI 一执行刚返回主程序循环又检测到int_flag仍然是 True于是立刻再次响应中断。表面上看起来像“主程序根本没恢复”实际就是中断请求标志没清。这个问题在真实 CPU 里同样存在只不过硬件会自动处理一部分模拟器里必须自己动手。4.2 我踩过的三个坑第一个坑中断检查的时机放在取指之后、执行之前。我早期写模拟器时图省事把中断检查放在主循环开头结果出现了一个诡异现象一条指令刚取出来还没来得及执行PC 就被保存并跳到 ISR 了。这意味着被“打断”的那条指令实际还没执行返回后会重新取指执行一次。而且在某些指令上会出现寄存器状态不一致的问题比如 ADD 操作数已经更新、结果没写回。后来我意识到真实 CPU 的中断响应都是在一个完整的指令周期结束之后才把检查时序改成了现在的样子。想通这一点整个模拟器的行为就和教科书的时序图对上了。第二个坑压栈保存断点的时机对不上。我在设计主循环时先执行了self.PC 1再执行指令最后检查中断。如果某条指令是 JMP执行后 PC 已经指向跳转目标这时如果中断发生保存的 PC 就是跳转目标的地址。这个行为是正确的。但如果不小心把 PC 自增放在 execute 之后就会把当前指令地址保存为断点返回后指令被重复执行一次。这类问题时序上差一点点现象却很难排查。我的建议是反过来推导写出“中断返回后应该执行哪一条指令”从结论倒推 PC 的更新时机。第三个坑调试输出本身影响判断。我一开始把打印信息写得特别密结果很多同学看输出时只盯着“响应中断”这几个字完全忽略了中断发生前 PC 的变化。后来我调整打印策略在响应中断时额外打印了“断点保护: 将当前 PCxx 压入栈”这一行才让大家一下子抓住重点。自己在设计模拟器时要刻意把关键事件中断请求、响应、返回打印得突出一些普通指令的执行信息压缩一点这样观察效率高很多。4.3 从模拟器到真实中断机制进阶方向跑通这个模拟器之后中断机制的基本概念就有了但真实系统的中断机制远不止这么简单。我觉得值得沿着这几个方向继续深入第一个方向是中断嵌套和多级优先级。现在模拟器在响应中断时直接关闭全局中断这对应的是真实 CPU 在进入 ISR 后自动cli关中断的做法。但真实系统里有时需要允许高优先级中断打断低优先级中断这就是中断嵌套。想模拟它很简单在response_interrupt里不要设置ie_flag False或者改成记录当前中断优先级只有更高优先级进来时才允许响应。栈天然支持这种嵌套所以代码改动不会太大。第二个方向是多中断源和中断仲裁。你可以给模拟器再加一个外部按键中断源两个中断源共享一条中断请求线但通过优先级仲裁决定先响应谁。这对应着真实系统中 8259A 的中断优先级管理也对应 ARM 处理器里 NVIC 的优先级分组机制。模拟起来就是用多个int_flag加一个优先级比较函数。第三个方向是把模拟器结果和真实 CPU 对照。比如 x86 架构里中断响应要做的事情包括压栈标志寄存器、压栈 CS 和 IP、根据中断向量号查中断向量表、跳转到处理程序、最后 IRET 弹栈恢复。你可以在模拟器里增加一个标志寄存器模拟 IF 位的变化然后对照 8086 的中断流程读英特尔手册会发现每个术语都能在模拟器里找到具体对应。这个对照过程非常有助于把模拟经验迁移到真实系统里。从我实际教课和做实验的体会来说中断机制的学习没有捷径最有效的方式就是一遍一遍地追踪 PC 和栈的变化。这个模拟器最朴素也最有用的操作就是运行一次然后盯着输出里每次“响应中断”前后的 PC 值确认压栈、跳转、恢复这三个动作用的数据完全对得上。如果你能不看代码提前说出执行顺序里下一步会发生什么说明你是真懂了。建议你拿到代码后先按默认参数跑一遍然后把 threshold 改小、把 ISR 改长、加第二个中断源一遍一遍地折腾。等到哪天你觉得这种模拟太小儿科、开始看书研究真实 CPU 的中断响应时序图那这篇博文的任务就算完成了。