51单片机的中断系统是很多人从“点亮一颗LED”迈到“真正做点事”的分水岭。点灯是顺序执行一条语句往下跑可一旦要同时兼顾按键、定时、串口靠主循环挨个轮询很快就抓瞎。中断就是专门解决“CPU忙不过来”这件事的没有中断的时候你得不停问每个外设“你有事吗”有了中断外设主动喊你你放下手头的事处理完再回去接着干。这篇文章不打算按数据手册的目录念经我把51中断系统的整体架构、寄存器配置、实战代码和这些年调试时踩过的坑整理成一份能直接照着做的清单适合刚学完GPIO想进阶的初学者也适合被中断问题反复折磨的开发者回头查漏。1. 先摸清51中断系统的家底中断源与响应全过程1.1 为什么轮询救不了实时响应先看一个很常见的场景。主循环里有一段延时驱动LED闪烁比如每隔2ms翻转一次同时你希望按键按下的时候切换显示模式。用轮询写主循环里有2ms延时按键在这2ms里按下再松开等你轮询到引脚的时候信号已经没了事件直接丢失。你以为把延时缩短到1ms就能解决其实只是把丢事件的概率变小并没有根治。轮询的本质是“CPU主动去问”而外部事件什么时候发生完全不可控。按键可能在延时中间按下串口数据可能在运算途中到达定时器溢出的那一刻你正好在查某个标志位。所有这些不确定性叠加起来程序越复杂越容易出问题。中断则反过来它是“外设主动通知CPU”——事件发生的时候硬件置一个标志位CPU在每个机器周期都会查一遍这些标志发现有请求就暂停当前工作跳到对应的处理函数。用生活里的例子说轮询就像前台每隔几分钟去门口看一眼有没有人找人来了只能在门口等着中断就像装了门铃有人按铃你立刻知道放下手头的事去开门就行。代价是门铃响的时候你在做什么都得先停一下于是就有了优先级、使能开关这些机制后面会逐个说到。1.2 五个中断源、入口地址与自然优先级对照表51内核的中断源一共五个这是所有项目的基础。下面这张表建议直接存下来后面配置寄存器的时候要反复对照。中断源请求标志入口地址自然优先级触发条件外部中断0INT0P3.2IE00003H最高1IT01下降沿IT00低电平定时器0T0TF0000BH2计数溢出外部中断1INT1P3.3IE10013H3IT11下降沿IT10低电平定时器1T1TF1001BH4计数溢出串口UARTRI / TI0023H最低5接收完成或发送完成自然优先级是硬件规定的不需要配置也无法改变。两个同一优先级的中断同时请求时先响应排在前面的那个。比如外部中断0和定时器0同时触发外部中断0先进定时器0和定时器1同时触发定时器0先进。入口地址这块C51写interrupt n的时候编译器会自动处理跳转很多人不关心这些地址。但如果你用汇编开发或者遇到中断函数莫名跑飞的情况就得知道每个中断对应一个固定地址。比如外部中断0的入口是0003H硬件触发后CPU会强制跳到这个位置开始执行这里必须放跳转指令否则会跑到别的代码里。1.3 一次中断从发生到结束硬件忙了哪四步中断看起来像是“瞬间发生”的实际上硬件有一套固定的处理流程。理解这套流程排查问题时思路会清楚很多。第一步触发条件满足硬件把对应的请求标志位置1。这个标志位是硬件的不是软件变量它在特殊功能寄存器里比如IE0、TF0、RI等。外部中断0产生下降沿IE0变1定时器0计数溢出TF0变1串口收到一帧数据RI变1。第二步CPU在每个机器周期末尾查询这些标志位。注意“查询”这个词中断并不是外设直接打断CPU而是CPU主动检查标志位。所以响应需要满足几个条件当前没有更高优先级或同级中断正在服务、当前指令已经执行完、中断总开关EA已经打开。还有一个冷门规则如果刚刚执行完RETI指令或者刚读写过IE、IP寄存器需要再执行一条指令才会响应后续中断。第三步硬件自动把当前程序的下一条指令地址压入栈然后跳转到对应中断源的入口地址。这一步是硬件完成的不需要软件干预。第四步执行中断服务函数遇到RETI指令后从栈里恢复之前的地址回到被打断的位置继续执行。RETI和RET不一样RETI会额外通知硬件“中断服务已经结束”这样同级和低优先级中断才能继续被响应。2. 四个寄存器一次讲透中断配置不再靠抄代码2.1 IE寄存器总开关和五个分开关一个不能少IE寄存器字节地址A8H可以位寻址。它是所有中断的使能开关类似总配电箱里的断路器。这位的分布是bit7是EA总开关bit4是ES串口bit3是ET1定时器1bit2是EX1外部中断1bit1是ET0定时器0bit0是EX0外部中断0bit5是ET2定时器2只有增强型51才有老89C51没有bit6保留。很多初学者第一次写中断照着网上的代码敲中断就是不响应最后发现EA没置1。这个位是总闸后面那些是分闸必须同时打开。比如外部中断0需要EX0 1; EA 1;两个都置1才生效。定时器中断就是ET0 1; EA 1;串口就是ES 1; EA 1;。为什么要设计总闸因为初始化外设是需要过程的。如果你先打开了分闸外设却还没配置好这个时候来一个中断请求CPU进去发现要用的东西都没准备好直接乱套。所以常见做法是先把所有分闸关掉逐个配好外设最后再统一打开EA。后面2.4节会专门讲这个顺序。2.2 TCON与SCON触发方式、请求标志与自动清零规则TCON寄存器是中断系统里最常用的一个它同时管外部中断和定时器。关键位如下bit0 IT0外部中断0触发方式。置1是下降沿触发清0是低电平触发。bit1 IE0外部中断0请求标志触发时硬件自动置1。bit2 IT1外部中断1触发方式规则同上。bit3 IE1外部中断1请求标志。bit4 TR0定时器0运行控制置1启动。bit5 TF0定时器0溢出标志。bit6 TR1定时器1运行控制。bit7 TF1定时器1溢出标志。这里面有个特别容易踩的坑标志位的自动清除规则不一样。外部中断0和1在边沿触发模式下进入中断后IE0/IE1会由硬件自动清0但在低电平触发模式下硬件不会自动清只要引脚一直保持低电平中断就会反复触发直到电平撤销。定时器TF0/TF1响应中断后硬件自动清0教科书里强调过实际很多人忘了这回事。而串口的RI和TI则完全相反必须在中断服务函数里手动清0不清的话中断会反复进入后面3.3节单独讲。SCON寄存器在串口部分用到主要关注bit1 TI和bit0 RI。TCON和SCON都是可位寻址的可以直接操作位名不用整个字节读写这也是教材里直接用IT01、RI0的原因。2.3 IP寄存器两级优先级的边界与嵌套限制IP寄存器字节地址B8H可位寻址作用是设置中断优先级。对应位PX0控制外部中断0、PT0控制定时器0、PX1控制外部中断1、PT1控制定时器1、PS控制串口增强型还有PT2控制定时器2。每一位置1表示高优先级清0表示低优先级默认全部为0。51的中断优先级只有两级没有更多。高优先级能打断低优先级这叫中断嵌套最多嵌一层同级之间不能互相打断只能排队等。两个同级中断同时来的话就按第1节那个自然优先级表决定先后。这个“两级”是硬件结构决定的和你愿不愿意没有关系。很多人一上来就想做出多级嵌套实际工程里反而要尽量避免嵌套因为每嵌套一层就要压栈51的片内栈空间很小压多了就溢出程序直接飞了。设计项目时IP寄存器一般和自然优先级配合着用。举个例子主循环里有一段耗时的显示扫描同时串口接收数据必须及时处理那就把PS 1;设成高优先级串口中断可以打断正在执行的其他中断和主循环而按键这种对时间不敏感的外部中断保持低优先级就够了。2.4 正确初始化顺序先配外设最后开EA我刚接触中断的时候初始化代码是想到哪写到哪结果时好时坏。后来总结出一个固定套路先配置外设的工作模式再开中断源最后开EA总闸。顺序不要反。void System_Init(void) { TMOD 0x21; // 定时器1方式2定时器0方式1 TH1 0xFD; // 串口波特率重装值 SCON 0x50; // 串口方式1允许接收 TR1 1; // 启动定时器1产生波特率 TH0 0xD8; // 定时器0初值 TL0 0xF0; IT0 1; // 外部中断0下降沿触发 EX0 1; // 打开各中断源分开关 ET0 1; ES 1; EA 1; // 最后开总闸 }为什么EA要放最后考虑一种情况定时器0先被启动了但EA还没打开此时它溢出一次TF0置1因为中断被禁止标志位就一直在那里。等EA打开的一瞬间CPU立刻发现TF0为1马上跳进中断而且可能连续响应好几次把系统时序全打乱。先配模式、开分闸最后开总闸可以保证中断使能的那一刻外设已经处于稳定状态不会产生积压的标志位。还有个小细节TMOD不能位寻址只能整体赋值或者用“与或”方式修改。比如TMOD 0xF0; TMOD | 0x01;这样改低4位不会影响高4位的定时器1配置。很多人直接TMOD 0x01;如果后面串口依赖定时器1这段串口初始化就被冲掉了排查半天才发现。3. 三个入门必做中断项目代码与调试记录全上3.1 外部中断按键控制LED一个反转背后的消抖门道先给一个最基础的版本外部中断0按键翻转LED#include reg52.h sbit LED P1^0; sbit KEY P3^2; void main(void) { IT0 1; // 下降沿触发 EX0 1; // 使能外部中断0 EA 1; // 开总中断 while (1) { // 主循环可以干别的事 } } void Ex0_ISR(void) interrupt 0 { LED ~LED; }这个版本在实验板上跑大概率会遇到一个现象按一次按键LED翻转好几次或者偶尔不翻转。原因是机械按键抖动。按键按下的过程并不是一次干净的电平变化簧片接触和弹开会在几毫秒到十几毫秒内反复通断每次通断都产生一个下降沿每次下降沿都触发一次外部中断LED就跟着多翻转几次。解决按键抖动有几条路。第一条是硬件滤波在按键引脚加RC低通滤波把高频抖动脉冲吸收掉对信号要求高的场合再加施密特整形这是最根本的办法但需要改电路。第二条是软件消抖中断里不直接处理业务只置一个标志位主循环看到标志后延时10毫秒再确认引脚状态确认确实按下了才执行动作。度代码如下#include reg52.h sbit LED P1^0; sbit KEY P3^2; volatile unsigned char key_flag; void delay_ms(unsigned int ms); void main(void) { IT0 1; EX0 1; EA 1; while (1) { if (key_flag) { key_flag 0; delay_ms(10); if (KEY 0) { while (KEY 0); // 等待松开 LED ~LED; } } } } void Ex0_ISR(void) interrupt 0 { key_flag 1; }这里有个重要细节key_flag必须声明为volatile。编译器不知道这个变量会被中断函数修改如果不加volatile它可能把key_flag优化到寄存器里主循环里永远读不到新值中断白触发。这是C51开发里最隐蔽的坑之一。再说说中断里为什么只置标志位。机械抖动的持续时间通常在几毫秒如果在中断里直接延时消抖CPU整个被卡在中断里外部中断0如果用的是低电平触发抖动期间会反复触发就算用边沿触发中断执行期间其他外设也得不到及时响应。把“事件发生”和“事件处理”分开中断只负责通知主循环负责处理这是嵌入式开发里特别重要的设计思路。改进后的代码也不是万能的它有个缺陷松键过程中产生的额外下降沿可能又置位key_flag导致一次按键动作后LED翻转两次。实践中的解决思路是等松开后清一次标志再退出或者直接用定时器周期性扫描按键做状态机消抖完全不用外部中断。具体选哪种看你的按键电路有没有硬件滤波没有的话建议优先上状态机方案可靠性高得多。3.2 定时器中断生成1秒时基初值计算与重装节奏定时器中断是延时精度的核心。先算初值假设晶振12MHz传统12T模式机器周期等于12除以晶振频率也就是1微秒。定时器0采用方式116位计数器从初值开始数到65536溢出。要定时10毫秒需要计数10000个脉冲初值就是65536减10000等于55536转十六进制是0xD8F0所以TH0写0xD8TL0写0xF0。完整代码如下#include reg52.h sbit LED P1^0; void Timer0_Init(void) { TMOD 0xF0; TMOD | 0x01; // 定时器0方式116位定时 TH0 0xD8; // 10ms初值高8位 TL0 0xF0; // 10ms初值低8位 ET0 1; EA 1; TR0 1; } void Timer0_ISR(void) interrupt 1 { static unsigned int count 0; TH0 0xD8; // 重装初值 TL0 0xF0; count; if (count 100) { count 0; LED ~LED; // 1秒翻转一次 } } void main(void) { Timer0_Init(); while (1) { // 主循环可以处理其他任务 } }这段代码有个必须知道的点中断里要重装初值。方式1是16位计数器溢出后不会自动恢复初值你不重装的话计数器从0开始数定时时长就从10毫秒变成了65.536毫秒完全乱套。重装的位置建议放在中断函数最开始不要等处理完业务再装否则越晚装定时偏差越大。定时精度的问题提一下。进入中断、执行重装指令都需要时间这段时间内计数器已经重新开始跑了与理论定时相比每次都会差几个机器周期。要求不高的场景比如LED闪烁、按键扫描这点误差可以忽略如果要用在时钟、PWM这类对时间敏感的地方有几种处理方式用方式2自动重装它是8位计数器溢出后硬件自动重装代码上没有重装耗时但只能定时较短周期或者在重装值里把中断响应开销补偿进去这个需要实测调整。实践中最省事的办法是让定时器中断频率高一些比如1毫秒一次在主循环里计数做时基综合性更好。还有一个容易忽视的定时器中断服务函数里的执行时间不能超过中断周期。10毫秒进一次中断如果函数里跑了一个20毫秒的延时下一次中断标志已经置位了等这次结束又立刻进中断看起来就是程序卡死、时序错乱。这个原则在任何中断里都成立。3.3 串口中断接收数据RI/TI不清零的连环坑串口中断是五个中断源里最特殊的一个因为它的标志位RI和TI都需要软件手动清除。这句话我已经提了两次因为太重要了。初始化如下#include reg52.h volatile unsigned char uart_buf; volatile unsigned char uart_flag; void UART_Init(void) { SCON 0x50; // 串口方式1REN1允许接收 TMOD 0x0F; TMOD | 0x20; // 定时器1方式28位自动重装 TH1 0xFD; // 11.0592MHz晶振9600bpsSMOD0 TL1 0xFD; TR1 1; ES 1; EA 1; } void UART_ISR(void) interrupt 4 { if (RI) { RI 0; // 必须手动清0 uart_buf SBUF; uart_flag 1; } if (TI) { TI 0; // 发送完成也要清0 } } void main(void) { UART_Init(); while (1) { if (uart_flag) { uart_flag 0; // 在这里处理收到的 uart_buf } } }SCON0x50换算成二进制是0101 0000SM00、SM11表示串口方式110位异步通信一帧8位数据REN1允许接收。波特率由定时器1产生方式2是8位自动重装所以TH1和TL1都写0xFD。这个值是在11.0592MHz晶振下用来得到9600bps的标准配置计算过程是定时器溢出率等于晶振频率除以12再除以256减TH1波特率等于溢出率除以320xFD代入正好接近9600。这也是为什么很多教学板子用11.0592MHz晶振它能整除出标准的9600、4800、19200用12MHz晶振配9600会有误差传数据多了就容易乱码。串口中断里最容易出问题的地方就是RI和TI的标志位处理。接收数据时RI置1如果不写RI 0;这个标志位会一直在中断触发一次之后马上又会触发主循环和中断死循环程序看起来就是卡死。发送完成时TI置1同样的道理。所以串口中断函数里对RI和TI的判断和清零一个都不能少。另一个坑是不要在串口中断里调用printf。printf内部会往发送缓冲写入数据并等待TI置位而TI恰恰正是当前中断的标志位之一要在中断服务里等TI等于自己给自己设套轻则延迟重则再也发不出去。正确做法是中断里只把数据存下来要回显或协议解析放到主循环去处理。数据多了可以加一个环形缓冲区中断里收数据入队主循环取出处理这是后续做串口协议栈的半成品基础。4. 中断调试实战不响应、乱响应、卡死的排查清单4.1 中断不响应按这六个方向逐项排查遇到中断不响应先别急着改代码按顺序检查这几项大多数问题能定位出来。现象可能原因检查方法所有中断都不响应EA没有置1看IE寄存器最高位单个中断不响应对应中断源没有使能检查EX0、ET0、ES等位外部中断一直不触发IT0/IT1触发方式配置不对示波器量引脚确认有跳变按键中断偶尔失灵按键抖动或引脚被复用检查P3.2/P3.3第二功能串口中断反复进入RI/TI没有软件清0进ISR后立刻清标志中断函数跑飞interrupt编号写错核对5个中断源编号4.2 优先级冲突、中断嵌套与栈空间的边界中断嵌套的情况用例子说最清楚。设外部中断0是高优先级定时器0是低优先级。定时器0正在执行中断服务时外部中断0来了它会打断定时器0的ISR外部中断0执行完RETI后再回到定时器0的ISR继续执行。这里多占用了一层栈空间因为被打断的现场需要暂存。如果两个中断都是低优先级外部中断0再急也要等定时器0执行完。嵌套带来的最大风险是栈溢出。51单片机的片内RAM本来就不多许多经典型号只有128字节栈是从某个地址往上增长的嵌套每深一层都要压入返回地址和可能用到的寄存器。我自己调试时遇到过嵌套两层之后程序复位的情况查到最后是栈空间被挤爆直接改了优先级设计才解决。实际工程里除非有非常明确的及时性要求否则不要设计多层嵌套结构把关键事件设为高优先级就够了。另外要注意在中断服务函数里修改IP或IE寄存器效果不会立刻生效并且可能造成当前中断响应逻辑混乱。原则上不要在ISR里改优先级优先级在初始化阶段一次定好。4.3 中断服务函数里的能与不能这是一个我一直强调的原则中断服务函数越短越好。它可以做的是给标志位置位、把数据从硬件寄存器拷贝到用户变量、清中断标志。你不要在中断里做的事包括长延时、复杂数学计算、大数组遍历、printf、malloc、以及任何可能让CPU停留超过几十微秒的操作。原因有两点。第一中断函数执行期间同级和低优先级中断都无法得到响应数据来了没人接收缓冲区溢出第二主循环被长时间挂起整体系统实时性反而下降。如果一个任务必须在中断里完成把任务拆成小块或者把不紧急的那部分放到主循环的状态机里。再说一个和编译器有关的点。C51里写的interrupt n函数编译器会自动生成入口代码和RETI指令并且在进入时保护它认为需要的寄存器。但如果用汇编写中断保护现场必须自己来PSW、ACC、B、DPTR这几个是最容易漏的。特别是DPTR主程序可能正在访问外部RAM中断里又用DPTR查表或写地址不保护的话回来就数据错乱。这类问题表现出来的现象往往特别奇怪程序时好时坏排查起来很费时间。最后分享一个我一直在用的调试习惯每个中断源第一次调通之前先在ISR最开头放一个计数器或者翻转一个不用的IO口先确认触发本身是正常的再往里填业务逻辑。这样做能把“中断配置问题”和“业务逻辑问题”切成两段哪一段出错一眼就看出来。51的中断系统本身并不复杂核心就是五个中断源、四个寄存器、一套固定的响应流程把这些框架理顺了剩下的就是在项目里反复用、反复踩坑、再反复积累经验。
阅读完成 · 觉得有帮助?