我的显示板上有一套定时器中断,2ms 进一次,里面重装计数器、送段码、换位选,一直很稳。后来想加个按键计数,顺手在中断里塞了几行,加完就开始出问题:跑十几分钟,屏幕上某一位置开始显示乱码,再久一点整个系统不响应。
查了两天才明白,问题不在新加的那几行,而在于我一直把中断当成「一个比较快的普通函数」在用。它不是。中断是在别人的代码中间插进去执行的,插进去的时候栈里还压着一堆东西,而且下一次中断随时会来。
下面这六条,是按我踩坑的顺序排的。
现象:主循环好好的,加了中断就乱
这类问题的共同点是「不立刻错」。跑几分钟、几十分钟才出现,而且每次现象不太一样 —— 有时候是变量变成奇怪的值,有时候是屏幕乱,有时候直接卡死。
因为真正的错误发生在栈或者寄存器上,表现出来却在别处。看现象找不出原因,得从「中断里能做多大事」这个角度倒推。
一、别在中断里调 printf
这是最要命的一条,也是新手最容易做的 —— 调试的时候想看看中断进来了几次,就在里面写一行 printf。
第一个原因:栈不够。51 的栈在内部 RAM 里,Keil 的默认配置 IDATALEN 是 128 字节,而 51 的栈是向上生长的,从 128 字节的底部开始往上,正好和你的变量区撞在一起。一次 printf 调用,加上格式解析、参数压栈,几十个字节很轻松。中断进来的时候栈里已经有主循环的现场,再叠一层 printf,很容易越过边界,把全局变量覆盖掉。
表现就是「某个全局变量莫名其妙变成了别的值」。你在主循环里怎么查都查不出来,因为它是在中断里被踩的。
第二个原因:printf 不可重入。Keil 的 printf 内部用一个共享的输出缓冲区。主循环正在格式化一个字符串,被中断打断,中断里又调 printf,两次调用共用同一块缓冲区,输出就串在一起了。串口那头看到的是两行内容交错,甚至半行。
正确做法是中断里只往一个环形缓冲区里塞字节,主循环里再取出来输出:
#define TXBUF_SIZE 64
unsigned char txbuf[TXBUF_SIZE];
volatile unsigned char tx_head = 0, tx_tail = 0;
/* 中断里:只塞一个字节,满就丢 */
void push_tx(unsigned char c)
{
unsigned char next = (tx_head + 1) % TXBUF_SIZE;
if (next != tx_tail) {
txbuf[tx_head] = c;
tx_head = next;
}
}
/* 中断里调用的日志函数:只记录事件码,不做格式化 */
void irq_log(unsigned char code)
{
push_tx(code);
}
格式化字符串这件事,全部留给主循环或者一个专门的后台任务。中断里只记录「发生了什么」,不负责「怎么显示」。
二、别在中断里做乘除法
8051 只有一条 8 位的乘法指令 MUL AB 和一条 8 位的除法 DIV AB,都只处理单字节。16 位以上就要链库函数,一次 32 位除法是上百个机器周期。
换算成时间:12MHz 晶振、12 分频,一个机器周期 1µs。一次 32 位除法就是几十到一百多微秒。浮点更狠,Keil 的浮点库一次运算几百到上千个周期,换算下来接近 1ms。
放到实际场景里:我的显示中断 2ms 一次,一次时隙只有 830µs。里面要是一个 32 位除法加一个浮点,光运算就吃掉大半,剩下的时间不够送段码,屏幕立刻开始闪。要是中断周期更短(比如 100µs),中断返回的时候下一次中断已经在等着了,主循环永远拿不到执行时间,看起来就是死机。
所以中断里只做这几类运算:加减、移位、位操作、比较。需要乘除的提前算好 —— 比如把「毫秒转成显示值」的系数提前算成一个表,中断里查表就够了。
/* 不要在中断里算,提前做成表 */
const unsigned int coef_table[16] = { 0, 62, 125, 187, /* ... */ };
void timer0_isr(void) interrupt 1
{
TH0 = T0H; TL0 = T0L; /* 重装 */
value = coef_table[idx]; /* 查表,不用乘除 */
send_segment(value & 0x0F);
switch_digit(pos);
}
三、别在中断里等
「等一下就好了」是中断里最危险的念头。等的东西只要有一点点不确定,整个系统的时基就崩了。
最常见的一种是等串口发完:
void uart_isr(void) interrupt 4
{
if (TI) {
TI = 0;
SBUF = next_byte();
while (!TI); /* 这里等,等到了再往下 */
}
}
看着好像没问题,实际上:9600 波特率下一个字节约 1ms,这 1ms 里 CPU 全停在这行上。如果外面还有 2ms 的定时器中断,它会被推迟,累计下来时基就漂了。
更糟的情况:如果这个串口中断的优先级比别的低,或者 TI 是靠另一个被关掉的中断来清的,那这个 while 永远等不到,直接死在这里。
同一类还有:等 ADC 转换完成(几十微秒到几毫秒)、等 EEPROM 写完(几毫秒,而且期间读会失败)、等 LCD 忙标志(看型号,几十微秒到几毫秒)、软件延时 delay_ms()。
判断标准很简单:中断里出现 while 循环等一个硬件标志,基本就是错的。 要么改成中断驱动(转换完成也开中断),要么把这件事挪回主循环。
四、共享变量要 volatile,多字节还要关中断
两个层次的问题,一层比一层隐蔽。
第一层:忘写 volatile。 主循环里 while (!flag) 等一个中断置位的标志,编译器看到这个循环里没人改 flag,就把它优化成「读一次,然后死循环」。中断里明明置了位,主循环就是出不来。
凡是「中断里写、别处读」或者「中断里读、别处写」的变量,声明时都要加 volatile。这是编译器层面的约定,不是可选项。
第二层:多字节变量的撕裂。 这个问题加 volatile 也解决不了。
51 是 8 位机,读一个 unsigned int 要两条指令:先读低字节,再读高字节。如果在这两条指令中间来了中断,中断里改了这个变量,主循环拿到的就是「旧的低字节 + 新的高字节」,一个根本不存在的值。
举个具体例子:中断里做 tick++,主循环判断 if (tick == 256)。当 tick 刚好从 255 变成 256 的那一瞬间,低字节是 0x00、高字节还是 0x00(因为更新前是 0x00FF),主循环读到的 tick 是 0x0000 = 0。判断没通过,这一圈就漏了。如果这个判断是用来做「每 256 次触发一次动作」的,那个动作就偶尔不做。
治法是把读取包起来,期间不让中断打断:
unsigned int tick_snapshot(void)
{
unsigned int v;
ET0 = 0; /* 只关这一个中断源 */
v = tick;
ET0 = 1;
return v;
}
用 ET0 关单路中断比用 EA 关总中断好 —— 关的时间更短,也不会把串口接收这种事一起挡掉。要是关的时间确实短(就两三条指令),用 EA 也可以接受,但要意识到它会影响到所有中断的响应。
反向也成立:主循环写一个中断也要读的多字节变量(比如「设置目标值」),同样要保护,不然中断可能读到一半旧的、一半新的。
五、清标志的时机
标志位清早了会丢事件,清晚了会重复进中断,顺序错了直接死循环。
串口接收:RI 在读完 SBUF 之后才清。顺序反了(先清 RI 再读 SBUF),在读的这段时间里新来的字节可能被覆盖,也可能 RI 又被置起来,导致同一帧处理两次。正确的写法:
void uart_isr(void) interrupt 4
{
if (RI) {
unsigned char c = SBUF; /* 先读 */
RI = 0; /* 再清 */
push_rx(c);
}
if (TI) {
TI = 0;
/* 发下一个字节 */
}
}
注意 RI 和 TI 共用同一个中断向量(中断 4),所以里面要分别判断,不能只判断 RI。
定时器:模式 1(16 位定时)在进入中断时硬件会自动清 TF0/TF1,不用手动清,但你必须在里面重装 TH0/TL0,否则下一次的周期就不对了。如果用的是查询方式(不开中断)而不是中断方式,TF 就要手动清。
外部中断:边沿触发的方式下,进入中断时标志会被自动清;电平触发的方式下,标志跟着引脚电平走,不清 —— 如果外部信号一直是低电平,中断返回后立刻又进去,主循环完全饿死,看起来就是死机。这种问题在现场经常被误判成「芯片坏了」。
最典型的症状:程序卡在中断里出不来,主循环一行都没执行。所以一旦出现「完全没反应但没死机(看门狗没复位)」,第一件事就是数中断里有没有漏清标志。
六、别调用会被重入的函数
这条跟第一条是一回事,但范围更大。中断里调用任何「主循环也在用」的函数,只要这个函数内部有共享状态,就会被重入打乱。
除了 printf,常见的还有:
1. LCD 的写命令/写数据函数。主循环正在写一串字符,中断插进来也去写,那条命令的时序被打断,屏幕显示乱码。
2. I2C 或 SPI 的收发函数。总线是有时序状态的,半途被插进来一条新的传输,从机直接不认。
3. 任何带静态缓冲区的字符串处理函数。
4. 动态内存分配(51 上很少用,但用了就更糟)。
原则:中断里用到的函数,必须是「除了参数和自己内部的局部变量之外不碰任何共享资源」的。做不到的话,就只在中断里置一个标志,让主循环去调用那个函数。
那中断里到底该干什么
一句话:只做「过了这一刻就再也拿不到」的事。
具体是这几类:
1. 读数据寄存器。串口收到一个字节,这一刻不读,下一个字节来了就覆盖了。
2. 清标志。不清就会重复进中断。
3. 重装计数器。晚了周期就错。
4. 翻转或拉高某个引脚。有严格时序要求的那种。
5. 把值存进缓冲区,或者置一个标志位。
其余的 —— 格式化、算数、写屏幕、发数据、做判断、决定下一步干什么 —— 全部退回主循环。
我现在的显示中断里就这几行:
void timer0_isr(void) interrupt 1
{
TH0 = T0H;
TL0 = T0L;
blank_all(); /* 先灭,防重影 */
P1 = seg_code[disp_buf[pos]]; /* 查表送段码 */
P2 = pos; /* 换位选 */
if (++pos >= DIGITS) pos = 0;
}
没有乘除、没有等待、没有 printf、没有函数嵌套,全部是寄存器操作和一次查表。整段执行时间压在一百微秒以内,占 830µs 时隙的十分之一多一点。
最后给一条能直接用的判定:把你中断里的代码从上到下看一遍,问自己「这段跑完要多久,有没有哪一行在等外面的东西」。 答不出具体时间、或者有一个 while 在等标志,就该拆出去。