学《计算机组成原理》的时候,中断机制是很多人第一道坎。教材上写得明明白白:中断请求、中断响应、现场保护、中断向量、中断返回……每个词都认识,但串起来就是不知道硬件到底干了什么。学校实验课如果安排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代码,看程序加载部分就能直接读出汇编逻辑。下表是整套指令:
| 操作码 | 助记符 | 功能 | 说明 |
|---|---|---|---|
| 0x0 | NOP | 空操作 | PC+4 |
| 0x1 | MOV Rx, imm | Rx = 立即数 | 支持负数(补码) |
| 0x2 | LOAD Rx, addr | Rx = MEM[addr] | 按字节地址取数 |
| 0x3 | STORE Rx, addr | MEM[addr] = Rx | 按字节地址存数 |
| 0x4 | ADD Rx, Ry, Rz | Rx = Ry + Rz | 通用加法 |
| 0x5 | SUB Rx, Ry, Rz | Rx = Ry - Rz | 通用减法 |
| 0x6 | JMP addr | PC = addr | 无条件跳转 |
| 0x7 | BZ Rx, addr | 若Rx==0则跳转 | 条件跳转 |
| 0x8 | INT vec | 软中断请求 | vec为中断向量号 |
| 0x9 | IRET | 中断返回 | 恢复现场 |
| 0xA | HALT | 停机 | 结束模拟 |
这套指令集里最点睛的设计是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指令 |
| 0x0004 | INT0中断向量表槽位 |
| 0x0008 | INT1中断向量表槽位 |
| 0x0100 | 主程序代码区 |
| 0x0200 | INT0中断服务程序代码区 |
| 0x0300 | INT1中断服务程序代码区 |
| 0x1EF8 | 栈区底部方向,由高向低增长 |
| 0x2000 | 数据区:中断计数 |
| 0x2004 | 数据区:软中断标记 |
| 0x2008 | 数据区:累加和结果 |
这个布局刻意模仿了教材里“低地址放中断向量表”的做法。真实CPU上,复位后PC指向0x0000,从那里开始执行;中断向量表放在固定低地址,每个向量槽位内存放的是该中断服务程序的入口地址。
2.3 中断向量表:教材和实现的一一对应
中断向量表是整个中断机制的寻址基础。在真实CPU里,外部设备发出中断请求后,CPU会根据中断号从向量表中查表,拿到中断服务程序的入口地址。常见做法是:向量表基地址 + 中断号 × 每个槽位宽度。
这个模拟器把中断向量表放在0x0004和0x0008两个槽位。0x0004对应中断向量0,0x0008对应中断向量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)函数把请求标志置1:
void 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~R5,ISR只用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 SR=0x%02X, save PC=0x%04X, SP=0x%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] PC=0x%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 PC=0x%04X, SR=0x%02X, SP=0x%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: PC=0x0000 SP=0x%04X SR=0x%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, vec=0\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] PC=0x0000: JMP 0x0100 [Cycle 1] PC=0x0100: MOV R1, 0 [Cycle 2] PC=0x0104: MOV R2, 10 [Cycle 3] PC=0x0108: MOV R3, 0 [Cycle 4] PC=0x010C: MOV R4, 1 [Cycle 5] PC=0x0110: INT vec=1 >>>>> [INT] ACK vec=1 at cycle 6 >>>>> save SR=0x01, save PC=0x0114, SP=0x1EF8 vector[0x0008] -> ISR entry 0x0300 [Cycle 6] PC=0x0300: LOAD R6, [0x2004] [Cycle 7] PC=0x0304: MOV R7, 100 [Cycle 8] PC=0x0308: ADD R6 = R6 + R7 [Cycle 9] PC=0x030C: STORE R6 -> [0x2004] [Cycle 10] PC=0x0310: IRET restore PC=0x0114, SR=0x01, SP=0x1F00 [Cycle 11] PC=0x0114: ADD R1 = R1 + R4 [Cycle 12] PC=0x0118: ADD R3 = R3 + R1 ...日志把响应过程讲得很清楚:第5个周期执行到INT vec=1,它在把PC指向0x0114后发出中断请求;紧接着CPU响应,保存断点0x0114和状态字0x01,从向量表查到ISR入口0x0300,跳转过去执行三条指令,最后IRET从栈里弹回0x0114,主程序继续跑。这就是整个“插队”过程。
第25个周期的外部中断日志大致如下:
... [Cycle 24] PC=0x0120: BZ R5 -> 0x0128 [no] >>> [SIM] External interrupt request, vec=0 >>>>> [INT] ACK vec=0 at cycle 25 >>>>> save SR=0x01, save PC=0x0124, SP=0x1EF8 vector[0x0004] -> ISR entry 0x0200 [Cycle 25] PC=0x0200: 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直接返回,日志里看不到ACK,ISR0也没执行,最终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 从模拟器到真实CPU:ISR现场保护做法
最后说一个我在真实嵌入式开发中踩过的坑。写完这个模拟器后,我在单片机上写串口中断程序时发现:如果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 PC=0x0114”和“restore PC=0x0114”完全一致时,突然理解了教材上那句“中断是程序执行流程的一次打断和恢复”。后来我去看真实处理器的数据手册,发现中断响应要执行的关中断、取向量、压栈、跳转,和我这个模拟器里干的事情一模一样,只是硬件把它们做成了几个机器周期内的自动动作。建议你跑完这套代码之后,再回到课本上看一遍中断响应流程图,这次你应该能看懂每一格真正在干什么了。