简介:面向计算机科学与技术等专业学生及单片机初学者,这份西南科技大学单片机课程设计2报告围绕AT89C51外部中断应用展开,可用于课程设计参考、实验报告撰写与中断编程入门。包内为1个docx文件,约477KB,内容按知识要求、能力要求、设计目标与任务、电路原理图、程序设计思路及关键代码组织,完整呈现声光报警器从需求分析到实现的全过程。报告以2个发光二极管、2个按键、1个数码管和1个蜂鸣器为载体,讲解中断源与中断标志、中断类型号、IE与IP寄存器设置、外部中断初始化及中断函数编写,并对比中断法与查询法的适用场景。读者可重点参考K1、K2触发后数码管显示、LED闪烁与蜂鸣器报警的控制逻辑,以及外部中断0、外部中断1服务程序和主循环去抖、状态恢复的代码实现。目前已有89人学习,适合需要一份结构完整、难度适中的单片机课程设计范例的读者。
1. 中断不是跳转:按下K1那一刻,CPU是被硬件强行改道的
很多人第一次看这段代码,会以为void int0() interrupt 0就是个被主程序调用的普通函数——按键按下,程序"跳"过去执行,执行完再"跳"回来。真相要粗暴得多:按下K1的那一瞬间,主循环while(1)里可能正执行到D1 = 1这条语句的取指阶段,CPU 会在当前这条指令执行完之后,把程序计数器压进栈,把 PC 强制改成 0x0003,全程没有任何一条指令征求过程序员的同意。查询法是"我主动去问按键有没有按下",中断法是"硬件替我盯着,一有下降沿就把我拽走"。差别不在代码长短,而在谁掌握时序的主动权。搞不清这一点,你就没法解释为什么中断服务程序里塞一个两秒的延时,整套报警器的响应会变成玄学。
2. 中断号与IE/IP:interrupt 0 和 interrupt 2 之间为什么夹了个1
2.1 五个向量地址决定了编号规则
初学者最容易卡住的细节是:外部中断0写interrupt 0,外部中断1却写interrupt 2,中间的 1 去哪了?答案是中断号根本不按"外部/内部"分类,而是按中断向量在程序存储区里的排列顺序发号。8051内核在低地址固定划出五个格子,每个格子只有8字节,放不下完整的中断服务程序,所以只能放一条跳转指令。谁排第几,谁就叫几号。
| 中断源 | 向量地址 | interrupt 编号 | 标志位 | 同级查询顺序 |
|---|---|---|---|---|
| 外部中断0(INT0) | 0x0003 | 0 | IE0(TCON.1) | 1 |
| 定时器0(T0) | 0x000B | 1 | TF0(TCON.5) | 2 |
| 外部中断1(INT1) | 0x0013 | 2 | IE1(TCON.3) | 3 |
| 定时器1(T1) | 0x001B | 3 | TF1(TCON.7) | 4 |
| 串口(RI/TI) | 0x0023 | 4 | RI/TI(SCON) | 5 |
这张表解释了两件事。第一,0x0003 到 0x000B 之间只有8个字节,编译器只能在那儿塞一条LJMP,真正的函数体放在别处,所以你写的int0()在物理上离向量表很远。第二,复位入口是 0x0000,紧挨着就是 INT0 的 0x0003,中间只隔3个字节——这意味着如果中断服务程序写得太随意,越界覆盖到向量区,程序会在下一次中断时直接跑飞,而且现象是"第一次能跑,第二次就死",非常难查。
初始化代码里那三行是整个系统的开关:
EA = 1; // 打开CPU总中断允许,这是最后一级闸刀 EX0 = EX1 = 1; // 分别打开外部中断0和外部中断1的局部允许 IT0 = IT1 = 1; // 设成下降沿触发,而不是低电平触发EA和EX0不是并列关系,而是串联关系。中断请求信号要穿过 EX0 这道门,再穿过 EA 这道门,才能到达内核。所以EX0 = 1但EA = 0时,IE0 标志照样会被硬件置位,只是 CPU 不理它;一旦你把 EA 打开,那个早就挂起的请求会立刻被响应。这个特性在调试时坑过不少人:单步跟踪时先把 EA 关了,走几步再打开,结果程序"莫名其妙"跳进中断——其实请求一直躺在 TCON 里没走。
2.2 IE寄存器:EA不是总开关,是串在最后一级的闸刀
IE 寄存器地址是 0xA8,支持位寻址,所以EA = 1这种写法本质是对 0xA8 的第7位做置位,编译出来是SETB EA一条指令。完整的位定义如下:EA(bit7)、保留(bit6)、ET2(bit5,仅52系列)、ES(bit4)、ET1(bit3)、EX1(bit2)、ET0(bit1)、EX0(bit0)。
为什么要有"局部允许 + 总允许"两层?因为这是嵌入式里非常典型的分层屏蔽设计:局部位负责功能级裁剪,比如某个模块暂时不需要外部中断1,关掉 EX1 即可;总位负责临界区保护,在修改共享数据的那几条语句前后EA = 0; ... EA = 1;,一次性屏蔽所有中断源,比逐个关再逐个开要省指令、也不容易漏。代价是总位一关,所有实时性都没了,所以临界区必须短——短到几条赋值语句,绝不能在里面调延时函数。
这里有个隐蔽的顺序问题值得说一句。如果你写成EX0 = 1; EA = 1;,在EX0 = 1执行完到EA = 1执行完之间,如果 P3.2 引脚上恰好有一个下降沿,IE0 会被置位挂起,等 EA 一开就立刻进中断——而此时你的数码管、蜂鸣器可能还没初始化完。稳妥的写法是先把所有端口状态、变量、显示缓冲区都初始化好,最后再统一开中断。中断是"事件驱动的入口",入口后面的世界必须是准备就绪的。
2.3 IP与自然优先级:两个键同时按下,为什么总是先显示1
IP 寄存器地址 0xB8,同样可位寻址:保留(bit7)、保留(bit6)、PT2(bit5)、PS(bit4)、PT1(bit3)、PX1(bit2)、PT0(bit1)、PX0(bit0)。复位后全为0,意思是五个中断源全部处于同一优先级。
同级并不意味着"随便谁先",硬件内部有一条固定的查询链:INT0 → T0 → INT1 → T1 → 串口。所以当 K1 和 K2 在同一瞬间被按下,两个下降沿几乎同时到达,IE0 和 IE1 都置位,CPU 会先响应查询链靠前的 INT0,数码管显示1,报警两秒,RETI返回之后再响应 INT1,显示2,再报警两秒。用户看到的现象是"按了两个键,它先叫了两秒,又叫了两秒",而不是"两个一起叫"。
那 IP 有什么用?把 PX0 置1,INT0 就升为高优先级。高优先级中断可以打断正在执行的低优先级中断服务程序,这叫中断嵌套。8051只支持两级优先级,同一级之间永远不能互相打断。这份设计里 IP 全默认,所以两个外部中断是平级的,谁也打断不了谁——INT0 那两秒延时执行期间,即使 K2 被按下,INT1 也只能干等着。这一点在后面讲长延时 ISR 的时候还会再撞上。
2.4 IT0=1之后,IE0到底由谁清零
TCON 寄存器地址 0x88,低4位管外部中断:IT0(bit0)、IE0(bit1)、IT1(bit2)、IE1(bit3)。IT0 是触发方式选择位,IT0 = 1表示下降沿触发,IT0 = 0表示低电平触发。这两种模式在"标志位谁来清"上的行为完全不同,是新手翻车的高发区。
下降沿触发时,硬件检测到高到低的跳变就把 IE0 置1,这个置位动作会锁存——即使引脚马上回到高电平,IE0 依然是1。等到 CPU 响应中断、跳向 0x0003 的那一刻,硬件自动把 IE0 清零。也就是说,标志位的生命周期由硬件闭环管理,你不需要在中断函数里写IE0 = 0。
低电平触发就麻烦了。只要引脚维持低电平,IE0 就一直为1,CPU 响应完中断返回后,一看电平还是低的,立刻又进一次中断,形成"中断风暴"。所以电平触发模式下,中断服务程序里必须保证在返回之前外部信号已经撤销,或者干脆用软件把请求位清掉。很多教材建议外部中断一律用下降沿,原因就在这儿——不是下降沿更"高级",而是它的标志清除是硬件包办的,少一个出错环节。
提示:如果在中断函数里手动写了
IE0 = 0,在下降沿模式下不会出错(本来就已经是0),但会让人误以为是软件清标志起了作用,后面换芯片或换编译器时容易踩坑。
3. 中断服务程序:interrupt 0 这行声明背后被塞进了什么
3.1 向量地址里的LJMP与入口处的压栈序列
interrupt 0这个关键字不是注释,它是在告诉编译器三件事:把函数入口地址登记到 0x0003 的跳转表里、在函数出入口自动生成现场保护和恢复代码、用RETI而不是RET结束函数。你写的是 C,编译器交出来的是这样一段结构:
ORG 0003H LJMP INT0_ISR ; 向量区只有8字节,放不下函数体,只能跳 ORG 0100H INT0_ISR: PUSH ACC ; 保护累加器,ISR里任何运算都会改它 PUSH B ; 保护B寄存器,乘除法会用 PUSH DPH ; 保护数据指针高字节 PUSH DPL ; 保护数据指针低字节 PUSH PSW ; 保护程序状态字,含进位标志和寄存器组选择位 ; ... 这里才是 P0 = 0xf9 和那个10次循环 ... POP PSW POP DPL POP DPH POP B POP ACC RETI ; 注意是RETI,不是RET压栈顺序和出栈顺序必须严格相反,这是栈的后进先出特性决定的。这五条 PUSH 不是摆设:主循环正在用 ACC 做运算、用 DPTR 做间接寻址的时候被中断打断,如果 ISR 不保护就直接改写这些寄存器,返回后主循环拿着被污染的中间值继续算,现象是"偶尔算错一次",而且极难复现。压栈的代价是每次进中断大概多花十几个机器周期,12MHz 晶振下约十几微秒——对两秒的报警延时来说微不足道,但对高频定时器中断就是实实在在的开销。
3.2 RETI与RET:不清优先级触发器会发生什么
这是最容易被忽略、后果又最严重的一个细节。RET和RETI都是把栈顶的两个字节弹回 PC,看起来效果一样,但RETI多做了一件事:清除内核里的"优先级激活触发器"。
这个触发器是硬件用来记录"当前正在服务某个优先级的中断"的状态位。它在响应中断时被置位,用来阻止同级中断再次进入(防止递归打断自己)。只有RETI能把它清掉。如果你在汇编里手滑写了RET,函数能正常返回,第一二次中断也看不出问题,但那个触发器一直挂着——从此以后,同级别的所有中断全部被永久屏蔽,程序表面上还在跑主循环,实际上再也进不了中断了。这种 bug 的可怕之处在于它有延迟性:按下第一次、第二次都正常报警,第三次开始彻底没反应,你会怀疑是按键坏了、是电源不稳,唯独想不到是返回指令写错了。
用 C 写的时候interrupt关键字会自动生成RETI,所以这份代码本身没这个问题。但只要你哪天在 ISR 里嵌了汇编,或者用函数指针间接跳转,就得盯紧这一条。
3.3 两秒长延时ISR:主循环被冻结,闪烁反而才看得见
现在来看这段最"反教科书"的代码:
void int0() interrupt 0 { P0 = 0xf9; // 数码管显示1 for(time = 0; time < 10; time++) { buzz = !buzz; // 蜂鸣器翻转 D1 = !D1; // 报警灯1翻转 delay_nms(200); // 延时200ms } P0 = 0xff; // 数码管熄灭 }10 次循环,每次延时 200ms,整个 ISR 的执行时间大约是 2 秒。这 2 秒里主循环while(1)是完全停摆的——它卡在D1 = 1之前或之后的某条指令上,一动不动。
这里有一个非常反直觉的连锁反应:主循环每一轮的第一件事就是D1 = 1; D2 = 1; D3 = 1; D4 = 1; buzz = 0;,把所有输出复位。如果 ISR 只是翻转一次 D1 就立刻返回,主循环下一轮(几十微秒后)就会把 D1 重新置1,灯根本来不及亮,肉眼看到的是"闪了一下但几乎看不见"。正是因为 ISR 霸占 CPU 整整 2 秒,D1 的高低位状态才能稳定保持 200ms 不被主循环抹掉。换句话说,闪烁之所以可见,靠的不是设计,而是"主循环被冻结"这个副作用。
蜂鸣器同理。buzz = !buzz每 200ms 翻转一次,产生的是频率约 2.5Hz 的方波——如果接的是无源蜂鸣器,它发出的不是连续报警音,而是"嗒、嗒、嗒"的断续声。想要那种刺耳的连续警报,得用定时器中断产生 1~2kHz 方波去驱动,而不是靠主循环或外部中断里的大延时。这是这份代码从"能演示"到"能当产品"之间最明显的一道坎。
把时间轴摊开看更清楚:
| 时刻 | CPU在做什么 | D1 | P0 | buzz |
|---|---|---|---|---|
| 0ms | 响应INT0,进入ISR | 1 | 0xf9 | 0 |
| 0~200ms | ISR第一次循环,主循环冻结 | 0 | 0xf9 | 1 |
| 200~400ms | 第二次循环 | 1 | 0xf9 | 0 |
| … | 反复翻转5个来回 | 翻转 | 0xf9 | 翻转 |
| 约2000ms | 退出循环,P0=0xff,RETI | 1 | 0xff | 最后状态 |
| 2000ms后 | 主循环恢复,第一轮就把所有输出复位 | 1 | 0xff | 0 |
注意退出时 D1 和 buzz 的最终状态是不确定的,取决于循环次数是偶数还是奇数——10 次是偶数,所以 D1 和 buzz 都回到了初值。这个"恰好正确"又是个隐患:如果把循环次数改成 11 次,灯就会灭不掉,得等主循环下一轮才复位。靠奇偶性保证正确状态,属于典型的"能跑但不可靠"。
3.4 不可重入函数与寄存器组:delay_nms被两边同时调用
delay_nms在中断里被调用,在主循环里也被调用(delay_nms(20)和delay_nms(3000))。这就引出一个问题:参数i存在哪儿?
Keil C51 默认把函数参数放在工作寄存器 R6/R7 里传递,而不是压栈。如果一个函数在执行到一半时被中断,中断里又调用同一个函数,第二次调用会把 R6/R7 覆盖掉,返回后第一次调用拿到的参数就变成了垃圾值——这就是不可重入函数的经典症状。表现出来是延时长度飘忽不定,偶尔短得离谱。
这份代码之所以没炸,是因为编译器的保护机制在兜底:Keil 在生成中断函数时,会把该函数及其调用树中用到的所有寄存器一并压栈。所以delay_nms用到的 R6/R7 会在 ISR 入口被保护,返回时恢复。代价是每次进中断多压几个寄存器,代码体积和响应延迟都上去了。
真正危险的是另一种写法——用using n关键字给中断函数指定独立的寄存器组。切换寄存器组确实能省掉压栈开销,但如果主循环调用的函数和被中断打断的函数用了不同的组,参数传递就彻底对不上了。血泪经验是:除非你非常清楚每个函数在用哪组寄存器,否则别碰using。默认配置下让编译器多压几个字节,比事后查一个"偶尔数据错乱"的 bug 便宜太多。
4. 段码0xf9到0x99:数码管的点亮逻辑藏着一个位序约定
4.1 共阳与共阴:为什么"0"才代表点亮
代码里P0 = 0xf9显示1,初学者第一反应是"0xf9 的二进制是 11111001,为什么不是 1 亮而是 0 亮"。答案在数码管的接法上。
七段数码管内部是7个(加小数点是8个)LED,它们要么共用一个阳极(共阳),要么共用一个阴极(共阴)。共阳接法下,公共端接 VCC,某一段的阴极拉低,电流才能流过那个 LED 点亮它。所以输出0代表点亮,输出1代表熄灭。共阴正好相反,公共端接地,段引脚给高电平才亮。
这份设计用的是共阳数码管,所以段码里的每一位都要取反着看。0xf9 = 1111 1001,从低位往高位读:bit0=1(a段灭)、bit1=0(b段亮)、bit2=0(c段亮)、bit3=1(d段灭)、bit4=1(e段灭)、bit5=1(f段灭)、bit6=1(g段灭)、bit7=1(小数点灭)。只有 b、c 两段亮,画出来正好是个"1"。这个推导过程和报告里写的一致,只是报告没点破"共阳"这个前提——而恰恰是这个前提决定了所有的取反关系。
4.2 位权映射:P0.0对a段,最高位留给小数点
数码管的八个段通常按 a、b、c、d、e、f、g、dp 的顺序编号,对应关系是固定的:
| 段 | a | b | c | d | e | f | g | dp |
|---|---|---|---|---|---|---|---|---|
| P0引脚 | P0.0 | P0.1 | P0.2 | P0.3 | P0.4 | P0.5 | P0.6 | P0.7 |
| 位权 | 0x01 | 0x02 | 0x04 | 0x08 | 0x10 | 0x20 | 0x40 | 0x80 |
最容易被记反的是"低位对a段"这条。有人习惯从左往右读二进制,把 a 段当成最高位,算出来的段码全错,显示出来是镜像或者乱码。稳妥的做法是把这张表贴在代码旁边,或者干脆写成查表法,让编译器去算:
// 共阳数码管 0~9 段码表,bit0对应a段,bit7对应小数点 // 共阳:位为0点亮,所以每个字节都要取反计算 const uchar seg_code[] = { 0xC0, // 0: a,b,c,d,e,f 亮,g灭 0xF9, // 1: b,c 亮 0xA4, // 2: a,b,d,e,g 亮 0xB0, // 3: a,b,c,d,g 亮 0x99, // 4: b,c,f,g 亮 0x92, // 5 0x82, // 6 0xF8, // 7 0x80, // 8 0x90 // 9 }; void show_digit(uchar n) { P0 = seg_code[n & 0x0F]; // 掩码防止数组越界,n超出9时不会读到别的内存 }查表法比在中断里现算要快得多,也更符合"段码是常量"这个事实。n & 0x0F这个掩码不是多余的——如果上层逻辑出错传进来一个大于9的数,没有掩码就会读到数组之外的内存,而中断里读飞内存的后果是直接死机,连报错都没有。
4.3 四个数字的推导与一张可校验的段码表
报告里给出的四个值,逐个验算一遍会发现都对得上:
| 数字 | 亮的段 | 二进制(dp,g,f,e,d,c,b,a) | 十六进制 |
|---|---|---|---|
| 1 | b, c | 1111 1001 | 0xF9 |
| 2 | a, b, d, e, g | 1010 0100 | 0xA4 |
| 3 | a, b, c, d, g | 1011 0000 | 0xB0 |
| 4 | b, c, f, g | 1001 1001 | 0x99 |
验算方法很简单:把要亮的位置写0,不亮的位置写1,从 a(最低位)往 dp(最高位)拼成字节。以"4"为例,需要 b、c、f、g 亮,即 bit1、bit2、bit5、bit6 为0,其余为1,拼出来是 1001 1001,正好 0x99。
值得强调的是,四个数字用的是同一套位序,所以只要会算一个,剩下三个是机械劳动。反过来说,如果你算出"1"是 0xF9 但"2"算成 0x25,那不是算法错了,是某一位的位序记反了——用表格逐位对一遍就能定位。
4.4 0xff为什么是熄灭,P0口又为什么必须上拉
P0 = 0xff表示八个位全为1,在共阳接法下所有 LED 都得不到低电平,全部熄灭。这个值被当作"清屏"用,在中断函数末尾把数码管关掉。
但这里有个硬件层面的坑,仿真软件不会提醒你:8051 的 P0 口是开漏输出,内部没有上拉电阻。开漏意味着它只能主动拉低,不能主动拉高——要输出高电平,必须靠外部上拉电阻把电平"提"上去。P1、P2、P3 口内部有弱上拉,P0 没有。
用 P0 直接驱动共阳数码管(拉低点亮)时,输出0没问题,但输出1的时候引脚处于高阻态,段上可能有微弱漏电流导致"熄灭不彻底",看起来是暗红色。如果 P0 还要读入(比如复用为数据总线),没有上拉就直接读不到正确电平。标准做法是给 P0 接一个 10kΩ 的排阻到 VCCb,或者在设计阶段就避开 P0,改用 P2 驱动数码管。这份代码选了 P0,在 Proteus 里能跑,焊到板子上大概率要补一个排阻。
5. 避坑与排查:仿真里跑通、焊到板上就翻车的五个原因
5.1 中断与软件层的三个坑
坑一:按一下K1,报警响了三遍。
现象是手指按一次,数码管和蜂鸣器却执行了两到三次完整的报警流程。原因在于机械按键的触点抖动——按下瞬间,金属触点在几毫秒内会快速弹跳多次,产生一串下降沿。代码里IT0 = IT1 = 1设的是下降沿触发,硬件对每一个下降沿都照单全收,于是抖出几个边沿就进几次中断。主循环里给 K3、K4 写了delay_nms(20)去抖,但外部中断0和1根本没有去抖,这是设计上的不对称。解决办法是在 INT0/INT1 的中断函数入口加一段软件延时再二次确认,或者硬件上并一个 0.1μF 电容加 RC 滤波,最彻底的是把按键扫描整体挪到定时器中断里做状态机。
坑二:按键松开后灯还亮着,程序像卡死了。
现象是按住 K3 时一切正常,松手后数码管常亮、蜂鸣器常响,其他按键完全没反应。原因在while (K3 == 0);这行死等——它在等按键释放,但这段时间里主循环彻底停摆,所有其他逻辑都执行不到。如果按键接触不良、一直保持低电平,程序就永久卡在这一行。更隐蔽的是,这行之前的delay_nms(3000)已经让系统停了3秒,用户体感就是"按下去就没反应了"。正确的做法是把"按下—延时—释放"拆成三个状态,用非阻塞的状态机推进,每次主循环只检查一次状态,绝不原地等待。
坑三:中断里改了标志,主循环读到的还是旧值。
现象是在 ISR 里对一个全局变量做了修改,主循环里的判断却始终不生效。原因是编译器优化:如果一个变量在主循环里被反复读、又没在同一个函数里被写过,编译器会认为它是不变的,把它缓存到寄存器里,不再去内存取值。ISR 改的是内存里的副本,CPU 读的是寄存器里的缓存,两者永远不同步。解决办法是给这类变量加volatile关键字,明确告诉编译器"这东西可能被中断改掉,每次都去内存读"。这份代码里time是全局变量,主循环没用到,所以侥幸没出问题,但只要后续扩展功能就会踩上。
5.2 硬件与端口层的两个坑
坑四:仿真里蜂鸣器响得欢,实物一点声音都没有。
现象是 Proteus 里波形完美,焊好板子后蜂鸣器纹丝不动。原因是 IO 口的驱动能力——8051 单个引脚灌电流能力大约十几毫安,而蜂鸣器(尤其是无源的那种)启动瞬间需要的电流远超这个数。直接接在 P1.6 上,电压被拉垮,蜂鸣器当然不响。正确做法是加一级三极管(NPN 或 PNP,看是高电平驱动还是低电平驱动)做电流放大,蜂鸣器串在集电极回路里,同时并一个续流二极管吸收反向电动势。仿真软件不会告诉你"这个引脚带不动",它只会老老实实按理想模型输出电压。
坑五:数码管在实物上暗淡或者干脆不亮。
现象是同一个段码,仿真里清清楚楚,实物上要么全暗,要么只有某几段微微发红。原因有两个叠加:一是前面说的 P0 口开漏、缺少上拉;二是共阳数码管每个段都需要限流电阻,直接接 IO 口时电流全部灌进单片机引脚,超过额定值会烧口,限流太大又不够亮。正确的设计是每段串一个 220Ω~470Ω 的限流电阻,P0 加 10kΩ 排阻上拉,需要更亮就再加一级 74HC245 之类的驱动芯片。这类问题在仿真里永远暴露不出来,只有上板才会见面。
注意:凡是"仿真通过"就以为万事大吉的项目,翻车点基本都集中在电流、上拉、抖动这三类物理特性上。仿真验证的是逻辑,不是电气。
6. 取舍:中断该干多少活,这套结构真正该改的三处
把整份代码拆完,会发现它的核心矛盾不是"写错了",而是"职责放错了位置"。中断服务程序的本质是一个必须在极短时间内返回的事件响应器,它应该只做"记录事件、置标志、唤醒主逻辑"这三件事,把所有耗时操作还给主循环或定时器。而这份代码让 ISR 干了 2 秒的活,还顺带完成了闪烁动画,等于把整台机器的时序控制权交给了中断。
第一处该改的是闪烁逻辑。把 200ms 的延时从 ISR 里拿掉,换成一个定时器中断(比如 T0 每 50ms 溢出一次),在定时器里累加计数,计到 4 次就翻转一次灯和蜂鸣器。这样外部中断只负责"把当前通道号写进一个全局变量然后立刻返回",响应时间从 2 秒降到几微秒,两个按键同时按下也能立刻被记录,不会互相阻塞。
第二处该改的是数码管的熄灭时机。现在P0 = 0xff写在 ISR 末尾,等于"报警结束才熄屏"。更合理的做法是熄屏交给主循环或者定时器,在报警状态位清零时统一处理,这样即使报警被中途打断(比如又来一个更高优先级的事件),显示状态也不会残留。
第三处是优先级。目前 IP 全默认,两个外部中断平级,谁也不能打断谁。如果业务上要求"后按的键能覆盖先按的键",就得把对应中断设为高优先级,或者干脆不用两个中断——用一个定时器统一扫描所有按键,按键事件进队列,主循环按队列顺序处理。后者的扩展性好得多,加到四个、八个按键都不用改中断结构。
验证改造是否成功,有个很土但有效的办法:在 ISR 入口和出口各翻转一个空闲 IO 口,用逻辑分析仪或者示波器看这个引脚的脉宽。改造前应该是 2 秒量级,改造后应该降到微秒量级;同时按下两个键,看两个通道的标志位是否都被记录。波形不会骗人,比盯着数码管猜靠谱得多。
从那以后我每次写中断函数,都会先在纸上画一条时间轴,把 ISR 的每一行标上预估耗时,只要总时长超过一个毫秒就强制自己重写。这套报告里的电路图、两张流程图和四组段码的完整推导都在原文档里,配合上面这些时序分析对照着看,比单看代码更容易把中断的整个响应链条在脑子里跑一遍。希望帮到你。
本文还有配套的精品资源,点击获取