
学《计算机组成原理》的时候中断机制是很多人第一道坎。教材上写得明明白白中断请求、中断响应、现场保护、中断向量、中断返回……每个词都认识但串起来就是不知道硬件到底干了什么。学校实验课如果安排FPGA还得先学Verilog再折腾开发板的引脚和时钟一节课下来一半时间在配环境。后来我给自己做了个“土办法”用C语言写一个简化版的CPU模拟程序把中断机制的整个响应过程在屏幕上一步步跑出来每条指令、每次压栈、每个跳转都打日志跑完一次实验中断机制那层窗户纸就彻底捅破了。这篇文章就把这套模拟程序的架构设计、完整代码和调试心得分享出来适合正在学计算机组成原理、做中断实验又被教材绕晕的同学。1. 为什么中断机制的实验适合用模拟程序来做1.1 真实硬件上做中断实验的障碍中断机制在真实硬件上演示最直观的方式是开发板外接按键按下按键触发外部中断CPU跳到中断服务程序改变某个LED状态。听起来简单但本科生做这个实验时问题很多。开发环境要配厂商SDK引脚要查原理图还要理解GPIO的中断触发方式这些前置知识反而盖过了中断机制本身。更要命的是中断发生极快如果没有示波器或者逻辑分析仪你根本看不到“当前正在执行的主程序被打断现场保存PC跳转”这个动态过程。教材上的流程图是静止的学生只能靠脑补。用模拟程序做这个实验最大的优势是过程可见。CPU内部每一个动作都对应一行日志什么时候关中断、什么时候压栈、压的是什么值、跳到哪个地址全部能打印出来。学习中断机制本质上就是学习这些动作的因果关系而模拟器恰好把因果关系摊开摆在桌面上。1.2 模拟器设计的三个核心目标我给自己定的目标有三个。第一硬件模型不能太复杂但关键机制必须保留包括寄存器堆、内存、程序计数器、状态字和中断向量表。第二指令集要简单到学生看一遍汇编就能记住同时又能支撑一个完整的中断实验。第三运行日志要详细到每一步动作都看得见尤其是中断响应前后的断点、现场和数据区变化。选用C语言而不是Python或者Verilog原因很简单C语言的指针和数组操作跟内存模型天然贴合一个uint32_t MEM[MEM_SIZE]就是一个存储器阵列而且C代码不依赖运行环境编译器一按就能跑。对做过计算机组成原理实验的人来说C语言本身没有任何心理负担。1.3 这个实验跑完你能看到什么实验程序会构造一个场景主程序正在计算1到10的累加和运行过程中软件主动触发一次软中断INT指令紧接着在第25个周期模拟一个外部设备拉高中断请求线触发第二次中断。每次中断都会跳转到对应的中断服务程序执行完再通过IRET指令回到主程序继续算。最终的数据输出会明确告诉你主程序算出了55但中间确实被中断“插队”了两次一次在0x2004地址写入了100另一次在0x2000地址把中断计数值加成了1。这个过程完美复现了教材上中断机制的完整生命周期请求、响应、保护现场、执行ISR、恢复现场、返回断点。2. 模拟CPU的架构设计指令集、寄存器、内存2.1 指令集设计与编码规则指令集是模拟器的基础。我不追求功能强大只要够用且好懂。全部指令固定为32位长度这样取指令就是一次内存读取不需要判断指令长度。编码格式分成五段高4位操作码次4位目的寄存器编号接着4位源寄存器1编号接着4位源寄存器2编号低16位立即数或跳转地址这个编码方式跟教材上经典的单周期CPU很接近只是砍掉了复杂寻址模式。我用一个宏来编码指令#define INST(op, rd, rs1, rs2, imm) \ ((((uint32_t)(op)) 28) | (((uint32_t)(rd)) 24) | \ (((uint32_t)(rs1)) 20) | (((uint32_t)(rs2)) 16) | \ (((uint32_t)(imm)) 0xFFFF))编译的时候一条汇编只对应一行C代码看程序加载部分就能直接读出汇编逻辑。下表是整套指令操作码助记符功能说明0x0NOP空操作PC40x1MOV Rx, immRx 立即数支持负数补码0x2LOAD Rx, addrRx MEM[addr]按字节地址取数0x3STORE Rx, addrMEM[addr] Rx按字节地址存数0x4ADD Rx, Ry, RzRx Ry Rz通用加法0x5SUB Rx, Ry, RzRx Ry - Rz通用减法0x6JMP addrPC addr无条件跳转0x7BZ Rx, addr若Rx0则跳转条件跳转0x8INT vec软中断请求vec为中断向量号0x9IRET中断返回恢复现场0xAHALT停机结束模拟这套指令集里最点睛的设计是INT vec。它把软中断请求挂到和外部中断相同的“中断请求线”上也就是说软中断和外部中断最终走同一条响应逻辑只是请求来源不同。这非常符合真实CPU的做法中断和异常在CPU内部统一处理只是触发源头不同。2.2 寄存器规划与栈的摆放模拟器设计16个32位寄存器比MIPS的32个更精简。寄存器编号固定下来代码里用枚举命名enum { R0 0, R1, R2, R3, R4, R5, R6, R7, SP_IDX 8, PC_IDX 9, SR_IDX 10, VEC_IDX 11 };R0到R7是通用寄存器其中R0在真实CPU比如MIPS里硬件强制为0我这里没有做硬件强制但编程时知道避开它就好。SP是栈指针PC是程序计数器SR是状态字寄存器VEC保存当前响应的中断向量号。SR的状态字我只用了一个标志位#define SR_IEN 0x01这个SR_IEN就是中断使能位对应教材上说的“开中断/关中断”。它在整个中断机制里的作用是核心中的核心后面的代码里你会反复看到它。内存方面我把存储阵列定义成uint32_t MEM[0x2000]也就是8192个32位字。访问时使用字节地址所以store_mem和load_mem内部要把地址右移两位再索引数组。栈顶初始化在0x1F00向低地址方向增长。堆栈区、程序区、数据区互相不冲突布局如下起始地址用途0x0000复位入口存放跳转到主程序的JMP指令0x0004INT0中断向量表槽位0x0008INT1中断向量表槽位0x0100主程序代码区0x0200INT0中断服务程序代码区0x0300INT1中断服务程序代码区0x1EF8栈区底部方向由高向低增长0x2000数据区中断计数0x2004数据区软中断标记0x2008数据区累加和结果这个布局刻意模仿了教材里“低地址放中断向量表”的做法。真实CPU上复位后PC指向0x0000从那里开始执行中断向量表放在固定低地址每个向量槽位内存放的是该中断服务程序的入口地址。2.3 中断向量表教材和实现的一一对应中断向量表是整个中断机制的寻址基础。在真实CPU里外部设备发出中断请求后CPU会根据中断号从向量表中查表拿到中断服务程序的入口地址。常见做法是向量表基地址 中断号 × 每个槽位宽度。这个模拟器把中断向量表放在0x0004和0x0008两个槽位。0x0004对应中断向量00x0008对应中断向量1。装载程序时往这两个地址写入ISR入口MEM[0x0004 2] 0x0200; // 向量0 - ISR0 MEM[0x0008 2] 0x0300; // 向量1 - ISR1当handle_interrupt响应中断向量vec时它用固定公式计算槽位地址uint32_t vector_slot 0x4 vec * 4; uint32_t isr_entry load_mem(vector_slot);这个公式完美对应教材的“向量地址 向量表基址 中断号 × 4”。只要搞懂这一行中断向量表的核心机制就理解了一半。3. 中断响应流程的模拟每一步都在干什么3.1 中断请求的检测机制真实CPU在每个指令周期末尾都会检查中断请求线。模拟器的主循环忠实还原了这个过程while (running) { execute_one_inst(); cycle_count; if (cycle_count 25) { request_interrupt(0); } handle_interrupt(); }先执行一条指令然后cycle_count加1接着检查有没有中断请求。注意这里的外部中断触发不是随机的而是模拟器在第25个周期主动拉高“中断请求线”。request_interrupt(0)函数把请求标志置1void request_interrupt(int vec) { intr_req 1; intr_ivec vec; }而handle_interrupt函数在前面检查两个条件一是有没有请求二是不是开中断状态。任何一个条件不满足就不响应。这个检查时机很重要CPU不会在执行指令“中途”响应中断必须等当前指令执行完毕。这就是教材上说的“指令执行结束后检查中断”。3.2 现场保护为什么压栈、压什么很多人第一次学中断最难理解的就是“现场保护”。教材说现场保护就是保护断点和程序状态字那到底保护的是哪两个值断点被中断指令的下一条指令地址本质上就是当前PC值。中断返回后要回到这个断点继续执行主程序。程序状态字这里就是SR寄存器。保存它是因为要恢复中断之前的“开中断/关中断”状态。真实CPU的中断响应第一步就是“关中断”防止在响应过程中又被新中断打断。我写代码时踩过一个经典的坑如果先改SR再压栈那压进去的SR就已经是关中断后的值了中断返回后会一直处于关中断状态下一次中断就没法响应了。所以必须先保存旧SR再去关中断uint32_t old_sr REG[SR_IDX]; REG[SR_IDX] ~SR_IEN; push(old_sr); push(REG[PC_IDX]);压栈顺序是“先压SR再压PC”。为什么要这个顺序这取决于IRET怎么恢复。栈是先进后出最后压进栈的PC会在栈顶IRET时先弹出的就是PC。所以栈结构是这样的初始SP - 0x1F00 压入SR后 SP - 0x1EFC 压入PC后 SP - 0x1EF83.3 向量寻址与ISR跳转保存完现场下一步就是从向量表取出ISR入口地址并跳过去。代码里这四步连在一起REG[VEC_IDX] vec; uint32_t vector_slot 0x4 vec * 4; uint32_t isr_entry load_mem(vector_slot); REG[PC_IDX] isr_entry; intr_req 0; intr_ivec 0;这一步恰恰是很多初学Verilog的人容易忽略的向量表里存的是入口地址不是指令本身。我见过有些人把一段ISR代码直接写在向量表里这是错的。向量表只管“查地址”然后PC被更新成这个地址下个周期从那个地址取指令执行。另外不要忘记清中断请求标志。如果响应完不清除intr_req主循环会在下一条指令后再次响应同一个中断导致程序陷入无限循环。这是当初让我排查了很久的bug后文常见问题里会再详细说。3.4 IRET的对称恢复IRET是中断响应的“逆操作”。前面压栈了什么这里就弹出什么。关键是弹出顺序必须和压栈顺序对称case OP_IRET: REG[PC_IDX] pop(); REG[SR_IDX] pop(); break;先弹PC再弹SR。因为压栈时最后压的是PC它在栈顶。恢复完PC和SR硬件还要把SP恢复原位这个pop()操作本身就会把SP加回去所以栈指针自动复原。IRET执行完之后CPU取出的下一条指令就是被中断前主程序里的下一条指令整个主程序无缝衔接继续运行。中断机制最神奇的地方就在这里ISR执行期间CPU完全“跑偏”了但只要现场保护和恢复做得对回到主程序后一切就像没发生过一样。真实CPU的IRET还会恢复更多现场比如通用寄存器和标志位。这个模拟器为了简洁ISR里直接避免使用主程序的关键寄存器主程序用R1~R5ISR只用R6、R7所以不需要压栈保护通用寄存器。但从严谨的角度讲实际项目中ISR必须把用到的通用寄存器全部压栈保存这一点在文末的常见问题里我会专门给出改进建议。4. 完整代码实现与逐段解读4.1 整体代码结构总览代码大概300行左右分成四个部分硬件定义、内存与栈操作、指令执行与中断处理、程序装载与主循环。读完一遍后你会发现整个模拟器的核心逻辑其实只有两个函数execute_one_inst和handle_interrupt。前者模拟CPU一条指令的执行后者模拟中断响应。4.2 内存访问与栈操作内存访问统一走store_mem和load_mem并把字节地址转换为数组下标void store_mem(uint32_t addr, uint32_t val) { uint32_t waddr addr 2; if (waddr MEM_SIZE) { printf([FATAL] store out of range at 0x%04X\n, addr); running 0; return; } MEM[waddr] val; }栈操作就是标准的向下增长模型压栈先减地址再写数据弹栈先读数据再加地址void push(uint32_t val) { REG[SP_IDX] - 4; store_mem(REG[SP_IDX], val); } uint32_t pop(void) { uint32_t val load_mem(REG[SP_IDX]); REG[SP_IDX] 4; return val; }这段代码本身不难但它是现场保护和恢复的基础设施后面中断响应全靠在它上面搭。4.3 指令执行的实现要点execute_one_inst里面有一个大的switch按操作码分支执行。最需要留意的两个指令是INT和IRET。执行INT时必须先把PC加4再挂中断请求case OP_INT: printf(INT vec%d\n, rs1); REG[PC_IDX] 4; request_interrupt(rs1); break;这个顺序千万别搞反。如果先请求中断再改PC那么中断响应保存的PC还是当前这条INT指令的地址IRET返回后又会重新执行一遍INT形成死循环。先加4意味着保存的断点是INT的下一条指令返回后直接往后走。而IRET在本模拟器里是恢复PC和SR恢复后打印关键状态方便观察。4.4 完整可运行代码清单下面是完整的、可以直接复制保存为interrupt_sim.c编译运行的代码#include stdio.h #include stdint.h #include string.h #define MEM_SIZE 0x2000 #define STACK_TOP 0x1F00 enum { R0 0, R1, R2, R3, R4, R5, R6, R7, SP_IDX 8, PC_IDX 9, SR_IDX 10, VEC_IDX 11 }; #define SR_IEN 0x01 enum { OP_NOP 0x0, OP_MOV 0x1, OP_LOAD 0x2, OP_STORE 0x3, OP_ADD 0x4, OP_SUB 0x5, OP_JMP 0x6, OP_BZ 0x7, OP_INT 0x8, OP_IRET 0x9, OP_HALT 0xA }; #define INST(op, rd, rs1, rs2, imm) \ ((((uint32_t)(op)) 28) | (((uint32_t)(rd)) 24) | \ (((uint32_t)(rs1)) 20) | (((uint32_t)(rs2)) 16) | \ (((uint32_t)(imm)) 0xFFFF)) #define GET_OP(I) (((I) 28) 0xF) #define GET_RD(I) (((I) 24) 0xF) #define GET_RS1(I) (((I) 20) 0xF) #define GET_RS2(I) (((I) 16) 0xF) #define GET_IMM(I) ((I) 0xFFFF) static uint32_t MEM[MEM_SIZE]; static uint32_t REG[16]; static int intr_req 0; static int intr_ivec 0; static uint64_t cycle_count 0; static int running 1; void store_mem(uint32_t addr, uint32_t val) { uint32_t waddr addr 2; if (waddr MEM_SIZE) { printf([FATAL] store out of range at 0x%04X\n, addr); running 0; return; } MEM[waddr] val; } uint32_t load_mem(uint32_t addr) { uint32_t waddr addr 2; if (waddr MEM_SIZE) { printf([FATAL] load out of range at 0x%04X\n, addr); running 0; return 0; } return MEM[waddr]; } void push(uint32_t val) { REG[SP_IDX] - 4; store_mem(REG[SP_IDX], val); } uint32_t pop(void) { uint32_t val load_mem(REG[SP_IDX]); REG[SP_IDX] 4; return val; } void request_interrupt(int vec) { intr_req 1; intr_ivec vec; } void handle_interrupt(void) { if (!intr_req) return; if (!(REG[SR_IDX] SR_IEN)) return; int vec intr_ivec; printf(\n [INT] ACK vec%d at cycle %llu \n, vec, (unsigned long long)cycle_count); uint32_t old_sr REG[SR_IDX]; REG[SR_IDX] ~SR_IEN; push(old_sr); push(REG[PC_IDX]); printf( save SR0x%02X, save PC0x%04X, SP0x%04X\n, old_sr, REG[PC_IDX], REG[SP_IDX]); REG[VEC_IDX] vec; uint32_t vector_slot 0x4 vec * 4; uint32_t isr_entry load_mem(vector_slot); printf( vector[0x%04X] - ISR entry 0x%04X\n, vector_slot, isr_entry); REG[PC_IDX] isr_entry; intr_req 0; intr_ivec 0; } void execute_one_inst(void) { uint32_t inst load_mem(REG[PC_IDX]); uint8_t op GET_OP(inst); uint8_t rd GET_RD(inst); uint8_t rs1 GET_RS1(inst); uint8_t rs2 GET_RS2(inst); uint16_t imm16 GET_IMM(inst); int16_t simm (int16_t)imm16; printf([Cycle %llu] PC0x%04X: , (unsigned long long)cycle_count, REG[PC_IDX]); switch (op) { case OP_NOP: printf(NOP\n); REG[PC_IDX] 4; break; case OP_MOV: printf(MOV R%d, %d\n, rd, simm); REG[rd] (uint32_t)simm; REG[PC_IDX] 4; break; case OP_LOAD: printf(LOAD R%d, [0x%04X]\n, rd, imm16); REG[rd] load_mem(imm16); REG[PC_IDX] 4; break; case OP_STORE: printf(STORE R%d - [0x%04X]\n, rs1, imm16); store_mem(imm16, REG[rs1]); REG[PC_IDX] 4; break; case OP_ADD: printf(ADD R%d R%d R%d\n, rd, rs1, rs2); REG[rd] REG[rs1] REG[rs2]; REG[PC_IDX] 4; break; case OP_SUB: printf(SUB R%d R%d - R%d\n, rd, rs1, rs2); REG[rd] REG[rs1] - REG[rs2]; REG[PC_IDX] 4; break; case OP_JMP: printf(JMP 0x%04X\n, imm16); REG[PC_IDX] imm16; break; case OP_BZ: printf(BZ R%d - 0x%04X%s\n, rs1, imm16, REG[rs1] 0 ? [taken] : [no]); if (REG[rs1] 0) REG[PC_IDX] imm16; else REG[PC_IDX] 4; break; case OP_INT: printf(INT vec%d\n, rs1); REG[PC_IDX] 4; request_interrupt(rs1); break; case OP_IRET: printf(IRET\n); REG[PC_IDX] pop(); REG[SR_IDX] pop(); printf( restore PC0x%04X, SR0x%02X, SP0x%04X\n, REG[PC_IDX], REG[SR_IDX], REG[SP_IDX]); break; case OP_HALT: printf(HALT\n); running 0; break; default: printf([ERROR] unknown opcode %u\n, op); running 0; break; } } void load_program(void) { MEM[0x0000 2] INST(OP_JMP, 0, 0, 0, 0x0100); MEM[0x0004 2] 0x0200; MEM[0x0008 2] 0x0300; MEM[0x0100 2] INST(OP_MOV, R1, 0, 0, 0); MEM[0x0104 2] INST(OP_MOV, R2, 0, 0, 10); MEM[0x0108 2] INST(OP_MOV, R3, 0, 0, 0); MEM[0x010C 2] INST(OP_MOV, R4, 0, 0, 1); MEM[0x0110 2] INST(OP_INT, 0, 1, 0, 0); MEM[0x0114 2] INST(OP_ADD, R1, R1, R4, 0); MEM[0x0118 2] INST(OP_ADD, R3, R3, R1, 0); MEM[0x011C 2] INST(OP_SUB, R5, R1, R2, 0); MEM[0x0120 2] INST(OP_BZ, 0, R5, 0, 0x0128); MEM[0x0124 2] INST(OP_JMP, 0, 0, 0, 0x0114); MEM[0x0128 2] INST(OP_STORE, 0, R3, 0, 0x2008); MEM[0x012C 2] INST(OP_HALT, 0, 0, 0, 0); MEM[0x0200 2] INST(OP_LOAD, R6, 0, 0, 0x2000); MEM[0x0204 2] INST(OP_MOV, R7, 0, 0, 1); MEM[0x0208 2] INST(OP_ADD, R6, R6, R7, 0); MEM[0x020C 2] INST(OP_STORE, 0, R6, 0, 0x2000); MEM[0x0210 2] INST(OP_IRET, 0, 0, 0, 0); MEM[0x0300 2] INST(OP_LOAD, R6, 0, 0, 0x2004); MEM[0x0304 2] INST(OP_MOV, R7, 0, 0, 100); MEM[0x0308 2] INST(OP_ADD, R6, R6, R7, 0); MEM[0x030C 2] INST(OP_STORE, 0, R6, 0, 0x2004); MEM[0x0310 2] INST(OP_IRET, 0, 0, 0, 0); } int main(void) { memset(MEM, 0, sizeof(MEM)); memset(REG, 0, sizeof(REG)); REG[SP_IDX] STACK_TOP; REG[SR_IDX] SR_IEN; REG[PC_IDX] 0; load_program(); printf( CPU Simulator with Interrupt Mechanism \n); printf(Reset: PC0x0000 SP0x%04X SR0x%02X\n\n, REG[SP_IDX], REG[SR_IDX]); while (running) { execute_one_inst(); cycle_count; if (cycle_count 25) { printf(\n [SIM] External interrupt request, vec0\n); request_interrupt(0); } handle_interrupt(); } printf(\n Simulation End \n); printf(Total cycles : %llu\n, (unsigned long long)cycle_count); printf(R1 (i) %u\n, REG[R1]); printf(R3 (sum) %u\n, REG[R3]); printf(R5 (diff) %d\n, (int32_t)REG[R5]); printf(MEM[0x2000] %u (ISR0 interrupt count)\n, MEM[0x2000 2]); printf(MEM[0x2004] %u (ISR1 mark value)\n, MEM[0x2004 2]); printf(MEM[0x2008] %u (sum stored)\n, MEM[0x2008 2]); printf(SP 0x%04X (stack pointer)\n, REG[SP_IDX]); return 0; }编译运行方式gcc interrupt_sim.c -o interrupt_sim ./interrupt_sim5. 运行结果与实验现象解读5.1 从日志看中断如何打断主程序程序运行后前半段日志大致是这样的[Cycle 0] PC0x0000: JMP 0x0100 [Cycle 1] PC0x0100: MOV R1, 0 [Cycle 2] PC0x0104: MOV R2, 10 [Cycle 3] PC0x0108: MOV R3, 0 [Cycle 4] PC0x010C: MOV R4, 1 [Cycle 5] PC0x0110: INT vec1 [INT] ACK vec1 at cycle 6 save SR0x01, save PC0x0114, SP0x1EF8 vector[0x0008] - ISR entry 0x0300 [Cycle 6] PC0x0300: LOAD R6, [0x2004] [Cycle 7] PC0x0304: MOV R7, 100 [Cycle 8] PC0x0308: ADD R6 R6 R7 [Cycle 9] PC0x030C: STORE R6 - [0x2004] [Cycle 10] PC0x0310: IRET restore PC0x0114, SR0x01, SP0x1F00 [Cycle 11] PC0x0114: ADD R1 R1 R4 [Cycle 12] PC0x0118: ADD R3 R3 R1 ...日志把响应过程讲得很清楚第5个周期执行到INT vec1它在把PC指向0x0114后发出中断请求紧接着CPU响应保存断点0x0114和状态字0x01从向量表查到ISR入口0x0300跳转过去执行三条指令最后IRET从栈里弹回0x0114主程序继续跑。这就是整个“插队”过程。第25个周期的外部中断日志大致如下... [Cycle 24] PC0x0120: BZ R5 - 0x0128 [no] [SIM] External interrupt request, vec0 [INT] ACK vec0 at cycle 25 save SR0x01, save PC0x0124, SP0x1EF8 vector[0x0004] - ISR entry 0x0200 [Cycle 25] PC0x0200: LOAD R6, [0x2000] ...注意这里保存的断点是0x0124也就是主程序里JMP指令的地址。说明中断发生在条件跳转执行完、JMP还没执行的时间点。返回后从0x0124继续执行完全无缝。5.2 数据结果分析与理论对照程序结束后的输出MEM[0x2000] 1 (ISR0 interrupt count) MEM[0x2004] 100 (ISR1 mark value) MEM[0x2008] 55 (sum stored)三个结果的物理含义分别是外部中断的ISR0执行了一次把0x2000从0加成了1软中断的ISR1执行了一次把0x2004从0加成了100主程序最终算出的累加和是55。求和结果55很关键。它说明即使主程序在中途被两次“打断”ISR也修改了R6、R7这两个通用寄存器主程序自身的循环变量和累加结果没受到任何影响。这就是中断机制的价值中断服务程序是短暂插入的一个“小任务”处理完后主程序原样继续算到哪从哪恢复。如果你把主程序换成更复杂的任务中断机制的这个特性依然成立。5.3 修改实验参数观察新行为这套模拟程序最大的好处是改参数容易可以观察不同场景下的中断行为。把外部中断触发周期从25改成8也就是在软中断执行之前就触发外部中断你会看到ISR0先执行然后主程序才继续走到INT 1执行ISR1。两次中断的相对顺序变化会直接反映在日志里。再试一个更有教学意义的实验把REG[SR_IDX]初始值从SR_IEN改成0也就是复位后默认关中断。运行后你会发现第25个周期的外部中断请求虽然触发了但handle_interrupt直接返回日志里看不到ACKISR0也没执行最终0x2000的值保持0。这就直观展示了“中断屏蔽”的效果请求已经发生但CPU因为关中断状态而不响应。在此基础上你还可以在ISR0里加一条MOV SR, 1来重新开中断看看会发生什么。如果这时又来一个更高优先级的中断CPU能否支持嵌套响应就要看现场保护是否足够严谨了。这就自然过渡到中断嵌套的实验。6. 常见问题与排查技巧6.1 中断一直不响应先查这几处我调试过程中遇到最多的问题是“日志里看不到ACK”。按这个顺序排查最有效第一检查中断使能位。看SR寄存器有没有SR_IEN这个位没有的话handle_interrupt第一道条件就过不去。可以在复位时强制打印SR的值确认。第二检查中断请求是否被清除。如果上一次响应后忘了把intr_req清零会导致中断反复触发看起来像“响应不过来”。第三检查中断号是否超出向量表范围。这个模拟器只有向量0和向量1如果传入向量2会去读0x000C地址万一那里是代码区就会跳到莫名其妙的位置。6.2 中断返回后程序乱跑多半是栈问题返回后程序乱跑大概率出在IRET的恢复顺序上。如果你把IRET写成先弹SR再弹PC那么栈顶的PC会被当成SR恢复下面埋着的SR会被当成PC跳转程序直接失控。记住一个原则压栈和弹栈严格对称。压的时候“先SR后PC”弹的时候就必须“先PC后SR”。另外检查栈指针的初始化地址。如果STACK_TOP设置得和代码区重叠压栈会把指令覆盖掉程序跑着跑着就执行到被覆盖的数据。我的布局里栈顶在0x1F00代码区在0x0100到0x0310数据区在0x2000附近三者完全没有重叠这也是规划内存布局时的重要考量。6.3 从模拟器到真实CPUISR现场保护做法最后说一个我在真实嵌入式开发中踩过的坑。写完这个模拟器后我在单片机上写串口中断程序时发现如果ISR里只用R6、R7没问题但只要中断处理函数用到了主程序也在用的寄存器就必须在ISR开头压栈保存、结尾弹栈恢复。真实CPU可没有“ISR只用专属寄存器”这种好事编译器生成的中断服务函数会自动加入压栈和弹栈代码但你如果写汇编就一定要自己记得保存现场。模拟器里可以这样改进在ISR入口处加两条push把R6、R7压栈IRET之前再弹出来。注意栈是先进后出压栈顺序和弹栈顺序要反过来比如// ISR0改进版 push(R6); push(R7); LOAD R6, [0x2000]; ... LOAD R7, [0x2004]; ... pop(R7); pop(R6); IRET;这段代码虽然简单但体现的正是真实操作系统中上下文切换的核心思想。中断服务程序本质上就是一个微型任务它的现场保护做得好不好直接决定了系统能不能安全地从中断中返回。我个人在实际写这个模拟器时最有收获的一刻不是看到55被算出来而是盯着日志里“save PC0x0114”和“restore PC0x0114”完全一致时突然理解了教材上那句“中断是程序执行流程的一次打断和恢复”。后来我去看真实处理器的数据手册发现中断响应要执行的关中断、取向量、压栈、跳转和我这个模拟器里干的事情一模一样只是硬件把它们做成了几个机器周期内的自动动作。建议你跑完这套代码之后再回到课本上看一遍中断响应流程图这次你应该能看懂每一格真正在干什么了。