
























这学期我抢到了学校开设的 51 单片机课程,想着借此入门嵌入式开发。在完成最近的一次作业时,我遇到了一个 bug,捣鼓了快一天才找出原因。此问题涉及 51 单片机的中断原理,我觉得还是有必要写成一篇博文记录一下的。
时-分-秒 格式显示。88-88-88-88 1 秒,然后恢复之前设置的时长,可进行下一轮倒计时。XX-XX-XX 改为 XX||XX||XX。
完成 51 单片机课程的第一次作业可能是我人生中第一次真正意义上的编程,那次经历让我真正体会到编程并不是直接上手写代码,而是先分析问题:我们要干什么?问题是怎样的?有没有简单的解决方法?这次我遵循了之前获得的经验。
这个程序要完成什么样的功能呢?首先按照状态列表格:
| 编号 | 状态 | 作用 | 进入 | 退出 |
|---|---|---|---|---|
| 0 | 初始 | 显示 00-00-10 | 模式 3 完成后进入 | 按 K2 至 1;按 K3 至 2 |
| 1 | 设置 | 设置时间:0.5s 点亮,0.5s 熄灭;K3 增加秒数,K4 减少秒数;长按:快速调整 | 模式 0 下按 K2 进入 | K2 至 0 |
| 2 | 倒计时 | 逐秒减少数字 | 模式 0 下按 K3 进入 | 自动退出至模式 3;按 K4 至 4 |
| 3 | 结束 | 显示 88-88-88,维持 1 秒 | 模式 2 完成后自动进入 | 自动退出至模式 0 |
| 4 | 暂停 | 暂停倒计时,显示 XX|XX|XX | 模式 0 下按 K4 进入 | 按 K4 至模式 2;按 K2 至模式 0 |
我们需要通过检测按键的按下来确定该转入什么模式,因此,按照按键列表格:
| 按键 | 短按 | 长按 |
|---|---|---|
| K2(设置) | 0:退出至 1,进入设置 1:退出至 0,进入初始 4:退出至 0,进入初始 | |
| K3(增加秒数、启动) | 0:退出至 2,开始倒计时 1:增加秒数 | 1:快速增加秒数 |
| K4(减少秒数、暂停) | 1:减少秒数 2:退出至 4,暂停 4:退出至 2,恢复倒计时 | 1:快速减少秒数 |
以下是我编写的代码:
1 | |
以 K3 为例,长按连发的流程图:
flowchart TD
A[每 1 毫秒 system_time 中断触发] --> B{is_k3_pressed}
B -- 0 --> Z[等待下一次中断]
B -- 1 --> C{K3 当前是否仍在按下}
C -- 否 --> D[短按]
D --> E[标志位清零]
C -- 是 --> F[长按]
F --> G[计算 K3 按下时长和上次增加时间间隔]
G --> H{是否满足连发条件}
H -- 不满足 --> Z
H -- 满足 --> I[增加时间]
I --> Z
Z --> A
J[按下 K3] --> K{is_k3_pressed}
K -- 0 --> L[is_k3_pressed 置 1]
L --> M[记录当前时间]
K -- 1 --> N[退出]
我以为这个程序写得非常完美,在愉悦中入睡了。然而,第二天起来后再测试就发现了一个 bug:倘若一上电就按下 K2,调整时间,不会出现任何问题。如果上电后先跑一轮倒计时,返回初始模式后再调整时间,则按下 K2 的一瞬间倒计时秒数会增加 1。这是因为 K3 除了启动倒计时,还有增加秒数的功能。但连续跑多轮倒计时或倒计时过程中多次按下 K3,进入设置模式后时间并不会增加更多秒数,仍旧只是增加 1。同样,在倒计时过程中按 K4,正负抵消,设定秒数不会发生变化。
我使用 Proteus 调试时发现 display_num 和 set_num 的自增/减是在进入设置模式后才发生的,其余模式下变量取值均正常。
为什么?我百思不得其解。我起初以为问题在切换进入 设置模式 的相关代码上,后来我觉得可能出在中断服务程序 system_time() 内,因为每隔 1ms 计数器就会溢出一次,执行频率非常高。但我读了好几遍相关代码,都没发现问题。
那么,一上电的状态和按下 K3/4 后的状态有什么区别,导致单片机执行结果不一样?我决定在 Proteus 调试中记录并比较两种情况下所有变量的取值。
在排除 IO 口、current_system_time 等无关紧要的变量后,可以看到有以下变量取值不同:
| 名称 | 地址 | 类型 | 刚上电时的取值 | 按下 K3 后取值 | 按下 K4 后取值 |
|---|---|---|---|---|---|
| IT1 | SFR Bit:8A | struct bit | 0b00000101 | 0b00000111 | 0b00001101 |
| IT0 | SFR Bit:88 | struct bit | 0b00000101 | 0b00000111 | 0b00001101 |
| IE1 | SFR Bit:8B | struct bit | 0b00000101 | 0b00000111 | 0b00001101 |
| IE0 | SFR Bit:89 | struct bit | 0b00000101 | 0b00000111 | 0b00001101 |
尽管 IT0/1、IE0/1 为单独位,但 Proteus 还是显示了一个 8 位二进制数。事实上,Proteus 显示的是整个 TCON 的值,选中这一项展开就可以查看 byte bitfield。因此,这四个不同实际上只是其中一位的不同:按下 K3/4 后,IE0/1 位被置 1。
| 位 | 名称 | 位地址 | 功能 |
|---|---|---|---|
| TCON.7 | TF1 | 0x8F | 定时器/计数器 1 溢出标志位 |
| TCON.6 | TR1 | 0x8E | 定时器/计数器 1 运行控制位 |
| TCON.5 | TF0 | 0x8D | 定时器/计数器 0 溢出标志位 |
| TCON.4 | TR0 | 0x8C | 定时器/计数器 0 运行控制位 |
| TCON.3 | IE1 | 0x8B | 外部中断 1 标志位 |
| TCON.2 | IT1 | 0x8A | 外部中断 1 触发方式选择位 |
| TCON.1 | IE0 | 0x89 | 外部中断 0 标志位 |
| TCON.0 | IT0 | 0x88 | 外部中断 0 触发方式选择位 |
这其实说明一件事:即使没有在中断允许寄存器(IE)中打开外部中断 0/1(EX0/1 = 1),单片机也会响应 P3.2/P3.3 电平的变化,并将相应标志位(IE0/1)置 1。这颠覆了我之前对中断的理解。我以为 IE 中 EA 是「总开关」、其余位是「分开关」,两类开关「串联」,只有两个开关都打开,单片机才会检测中断源并响应中断。
我们以外部中断 0(INT 0)为例,编写一个简单的程序来验证这件事,倘若标志位 IE0 被置 1,则点亮 LED:
1 | |
这是为什么?回到倒计时器程序,可以看到在初始化时开启了 EA。现在取消上方代码中第 5 行的注释,就会发现 LED 点亮了;但是松开按键,LED 却又熄灭了。

与倒计时器程序进行比较,可以发现,倒计时程序使用的是下降沿触发:
1 | |
我推测低电平触发与下降沿触发的检测原理不同。低电平触发时,单片机会一直检测相应引脚的电平状态,低电平时相应标志位置 1,高电平时清零;下降沿触发时,单片机检测到相应引脚的电平降低就将相应标志位置 1,并不会自动清零,响应中断后才会清零。
也就是说,这种中断任务「挂起」、启用中断后迅速执行导致的问题只存在于下降沿触发的外部中断。第一次遇到这种问题好像觉得非常麻烦,但这其实是一种非常精妙的设计:如果需要处理许多中断,而低优先级中断是下降沿触发的,倘若不这样设计,正在处理一个高优先级中断的时候,触发低优先级外部中断,这个低优先级中断就会被忽略,无法触发。
下降沿触发和低电平触发的用途不同。前者主要用于对「事件」的检测,譬如按下按键,只要检测到按键按下这一事件发生,就触发中断。即使现在无法执行中断服务程序,也要「记下来」在合适的时候执行。而后者主要用于对「状态」的检测,只要处于某种状态,就触发中断。并且这种状态具有时效性,一旦不再处于这种状态,就没必要做出中断,因此并没有设计「中断任务挂起」这样的功能。
类似地,我验证了定时器中断与下降沿触发的外部中断相似:只要 EA = 1,并打开记时器开关 TR0/1,即使 ET0/1 = 0,TF0/1 也会置 1。
1 | |
我想这样表述我的发现:
中断允许寄存器(IE)最高位 EA 是检测中断触发条件的开关,如果 EA = 1,CPU 会检测所有中断源触发条件,对相应的中断标志位进行操作。低电平触发时,单片机会检测相应端口是否为低电平,若是,则将标志位置 1;下降沿触发时,单片机会检测相应端口电平是否出现下降,若是,则将标志位置 1。
而 IE 中的局部使能位是检查中断标志位的开关,只有对应位为 1 时,CPU 才会检查该中断的标志位;若标志位也为 1,才会响应中断。
试想一下,如果单片机响应中断的原理真是这样,运行下面的程序会有什么样的结果呢?
1 | |
按下按键后,LED 会常亮,无论按键是否松开。因为外部中断 0 为低电平触发,P3.2 为低电平的第一个时刻,IE0 被置 1,执行 if 中的内容,点亮 LED 之后,又关闭了 EA。这样单片机不会再更改 IE0 的值,LED 也就常亮了。事实上,这也正是实际的运行结果。
这就解释了程序 bug 的原因:虽然没有启用外部中断 0/1,但由于外部中断 0/1 被设置为下降沿触发,其标志位依然在被按下时被置 1,中断被「挂起」没有响应。进入 设置模式 后,启用了外部中断 0/1,单片机会瞬间产生中断响应。
但我还有一个问题,中断标志位什么时候清零呢?可以运行一下这个程序:
1 | |
这样,触发外部中断 0 后,会一直卡在中断服务程序出不来。LED 不亮说明按下按键后立刻进入中断服务程序,还没来得及执行主函数中的判断和赋值。无论怎样按按键,LED 均不亮,但使用 Proteus 调试发现:第一次按下按键,IE0 为 0;第二次按下按键,IE0 保持为 1。
这说明对于下降沿触发的外部中断,CPU 在执行中断函数前就已经自动将标志位 IEx 清零。而第二次按下按键时,CPU 卡在中断的死循环中。而中断不能被同优先级或低优先级中断打断,因此 CPU 无法对标志位清零,IE0 最终保持为 1。(下降沿触发,标志位的置位可能是由硬件完成。我先让 CPU 陷入一个高优先级中断的死循环,再按下按键,发现 IE0 依然能置 1。定时器中断我也测试过了,与下降沿触发的外部中断结果相同。)
将上方程序的第 6 行删掉,就可以测试低电平触发的外部中断将标志位清零的时刻。经过我的测试,LED 始终不亮。但与下降沿触发不同,IE0 只在按键按下时为 1,一旦按键松开就清零。这说明低电平触发的外部中断标志位的置位和清零也不占用 CPU 的执行时间,可能是由硬件完成的。
至于低电平触发的外部中断在跳转中断服务程序之前会不会将标志位清零,我猜测是不会的,因为确实没有这样做的意义。但其实比较难通过编写程序测试出来,我并没有测试。
| 中断类型 | 中断标志位置位(EA = 1) | 中断标志位清零(EA = 1) |
|---|---|---|
| 外部中断(下降沿触发) | 硬件(检测到端口电平降低) | CPU(软件)(在跳转中断服务程序前) |
| 外部中断(低电平触发) | 硬件(检测到端口为低电平) | 硬件(检测到端口不为低电平) |
| 定时器中断 | 硬件(计数溢出) | CPU(软件)(在跳转中断服务程序前) |
知道了标志位置位的原理,修复问题就很简单了,只需要在进入 设置模式 之前将 IE0/1 清零即可:
1 | |
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。