今天是自学嵌入式第二十三天。前半段我在学GPIO输出、按键扫描、串口打印这些基本功,虽然能跑通小实验,但总觉得哪里不对劲——程序里到处是while等待、轮询标志位,CPU大部分时间都在空转。直到今天把中断系统地过了一遍,才感觉自己勉强摸到了“事件驱动”的门。这篇文章不打算写教程式的推演,就记录一下我在第二十三天踩过的坑、理过的思路,以及中断这个东西到底该怎么理解才算真正入门。
1. 第二十三天了,中断为什么是绕不过去的一道坎
1.1 前22天我能做到什么程度
先交代一下背景。我手头没有买很复杂的开发板,就是一块很小的板子,上面焊了一颗Cortex-M3内核的MCU,外设口引出来,接面包板还算方便。前22天,我的学习路径大概是这样的:最开始几天在搞GPIO,点灯、灭灯、跑马灯,感受一下寄存器和库函数之间的区别;接着开始读按键,用的是最笨的轮询方式,主循环里面不停地去读IO电平,配上delay来消抖;再往后学了串口发送,把printf重定向到串口助手,把传感器数据打印出来看。
走到这一步时,我自我感觉还挺好的。至少“看手册、查寄存器位、写代码烧录、看现象”这条路已经能跑通。遇到问题知道去翻数据手册,知道用调试器看寄存器,不再像第一周那样四处乱撞。
但是第二十三天开始之前,我心里其实已经出现一个很明显的不适感:程序的写法越来越别扭。
1.2 查询方式的瓶颈在哪里
问题的根源在于“所有事情都要主循环亲自去盯着”。比如我想做一个按键长按和短按的区分,主循环就必须高频地去扫描按键,同时维护一个计时变量,还要兼顾LED闪烁、串口打印等任务。
看起来好像也能跑,但一旦某个外设的事件来得不及时,比如串口突然收到一帧数据,轮询就要等好几个循环周期才检查到。我在实验中发现,当我把主循环里的任务从两三个加到四五个之后,按键响应延迟肉眼可见地变大,串口数据偶尔还会丢字节。这就暴露了查询方式的两个核心痛点:第一,CPU被反复空转浪费,大部分时间花在检查“事件发生没有”上;第二,事件响应延迟取决于主循环的长度,主循环越长,越容易漏掉突发信号。那天我实在忍受不了按键偶尔失灵的现象,决定把中断好好学一遍。
1.3 第二十三天的具体学习目标
这一天的目标我写得很具体:
- 能从原理上回答“中断是什么、进入中断后CPU做了什么、退出时怎么回来”,不能再停留在“调用一个回调函数”的层面。
- 能够自己动手配置一个外部中断:按键下降沿触发,在中断服务函数里翻转LED状态。配置过程不硬背库函数,先看懂寄存器怎么操作。
- 复现并排查几个常见问题:中断不触发、重复触发、在中断里加了延时导致主流程卡死。
- 当天晚上把定时器中断也跑一遍,理解预分频和重载值的计算。
说实话,这天的知识密度比前面任何一天都高,因为中断涉及硬件的向量表、内核的中断控制器、外设的事件标志、代码的可重入性好几个层面。但正因为它难,才是嵌入式自学的分水岭。
2. 先从原理上把中断啃明白
2.1 中断到底在讲什么
简单说,中断是硬件主动通知CPU“有事情发生了,先别干现在手上的活,来处理一下我”。而查询方式是CPU自己一直去看外设状态,相当于在门口站着干等。
我后来想了一个比较容易记住的类比。查询方式就像你自己写材料写到一半,每隔几分钟就去门口看快递有没有来,期间材料写得稀碎;中断方式则是有个快递员按门铃,你停下笔去签收,签完回来继续写。这里的“门铃”就是中断请求,“门铃响了之后放下笔、记住刚才写到哪一行、签收完再拿起来写”就是中断的进入、保存现场和恢复现场。
具体到Cortex-M3这样的内核,整个过程是硬件自动完成的:外设产生中断事件,处理器在合适的指令边界响应,把当前正在执行的指令地址和必要寄存器压入栈,然后从向量表取出对应中断服务函数的入口地址,跳过去执行。执行完ISR之后,再弹栈恢复现场,回到主流程继续。这个“现场保存与恢复”不需要程序员手写,是内核硬件负责的,但理解它非常重要,否则你没法解释为什么中断服务函数结束后程序不会乱跑。
2.2 优先级、嵌套与临界区
优先级这个概念,我刚开始以为只是“多个中断同时发生时先处理谁”,其实远不止这么简单。以Cortex-M3内核为例,中断控制器的优先级分为抢占优先级和子优先级:抢占优先级决定能不能打断别人,子优先级只决定同一个抢占优先级下的响应顺序,不能产生嵌套。
这意味着设计的时候要想清楚:按键中断如果抢占了串口接收中断,两个中断服务函数里如果同时操作同一个全局变量,就可能出现数据错乱。我第二天就在调试中发现,主循环和中断服务函数没有用volatile修饰一个标志变量,结果优化一开,主循环永远读不到中断里改写过的值。加上volatile只是第一步,真正涉及共享数据的复合操作,还要靠临时关中断来保护,这就是我们常说的临界区。这个理解很关键,很多新手在中断上翻车,不是不会配置,而是根本没有意识到优先级和临界区的问题会在一个小项目里真实出现。
2.3 中断标志位:触发与清除是一对
中断服务函数的第一行和最后一行,往往都跟“标志位”有关。外设检测到事件,会把它对应的事件标志位置为1,这时候CPU才知道要处理。进到ISR之后,这个标志位必须清掉,否则中断服务函数一退出,外设看到标志还是1,又会立刻再次触发。
标志位清除方式因外设而异,有的是读寄存器自动清,有的是写1清,有的是写0清。以常见的外部中断为例,通常是对挂起寄存器写1来清除。不少新手直接给中断标志位寄存器赋0,以为也能清,结果发现中断疯狂触发,或者是标志一直没清干净导致只响应了一次。那次之后我养成一个习惯:每接触一个外设的中断,先看数据手册里“clear procedure”这一小节,搞清楚是“写1清零”还是“读后自动清零”,不再想当然。
3. 实操:按键外部中断驱动LED,手把手走一遍完整流程
3.1 实验器材与接线
这次实验我用到的器材非常基础:一块带Cortex-M3内核MCU的开发板,一个轻触按键,一个发光二极管,还有若干杜邦线和面包板。
接线方式:LED正极串一个330欧姆限流电阻,接在MCU的一个普通GPIO输出口上,负极接GND;按键一端接GND,另一端接MCU的另一个GPIO输入口。这里有个细节:按键的另一端不能悬空,否则引脚电平不确定,会误触发中断。所以这个GPIO必须配置成内部上拉输入,不按的时候是高电平,按键按下时引脚被拉到GND,形成下降沿。
外部中断触发方式一般有上升沿、下降沿和两个沿都触发三种选择。按键按下瞬间产生下降沿,所以我用下降沿触发。硬件上确认好引脚号之后,接下来所有配置都围绕这个引脚展开。
3.2 寄存器层面的配置顺序
我先把配置步骤理出来,这段是所有类似MCU都通用的逻辑,具体寄存器名会有些差异。
第一步,开启GPIO时钟和外部中断复用时钟。很多MCU的外设引脚默认不接在芯片内部外设上,需要先把引脚从普通IO模式切换到外部中断模式,这个切换依赖时钟使能。忘记开时钟是中断不触发最常见的低级错误。
第二步,配置GPIO模式为输入,使能内部上拉电阻。如果这一步设成了推挽输出,按键怎么按都不会产生正确电平。
第三步,把GPIO引脚映射到外部中断线,并选择触发边沿。例如引脚0对应外部中断线0,触发方式选择下降沿。
第四步,使能外部中断线本身,同时使能中断控制器中对应的中断通道。这两层都使能,事件才能到达CPU。
第五步,写中断服务函数。函数名必须与中断向量表里定义的名字一致,不同的芯片工具链会有差异,我手头的工程里直接用向量表里已有的函数名。函数内部最基本的逻辑是:判断是否是我关心的那条中断线产生的标志,是则清除标志位,然后做我要做的事。
实际配置代码看起来大概是这样,以寄存器操作风格为例:
// 1. 开启时钟(具体位因芯片而异) RCC->APB2ENR |= (1 << 0); // 开启GPIO时钟 RCC->APB2ENR |= (1 << 14); // 开启外部中断复用时钟 // 2. 配置引脚为输入模式,带上拉 GPIO->CRL &= ~(0x0F << (4 * 0)); GPIO->CRL |= (0x08 << (4 * 0)); // 上拉输入 // 3. 映射引脚到外部中断线0 EXTI->IMR |= (1 << 0); // 使能中断线0 EXTI->FTSR |= (1 << 0); // 下降沿触发 EXTI->PR = (1 << 0); // 进入前清理一次挂起标志 // 4. 使能中断控制器中对应的中断通道 NVIC->ISER[0] = (1 << 6); // 假设中断通道号为6这组代码的精神是先复用时钟,再配置IO,再挂中断源,最后开NVIC。顺序看似简单,但颠倒顺序往往出问题,比如先开NVIC再使能外设中断线,事件来了也进不去。
3.3 中断服务函数怎么写得安全
中断服务函数是在主流程中强行插进来执行的,它跟普通函数最大的区别是:它运行在一个特殊的上下文里,不能随意花费太多时间。我这次实验的服务函数就只做三件事:判断标志、清标志、把LED状态标志位取反。主循环里检测到标志后,再真正操作LED。
volatile uint8_t g_button_pressed = 0; void EXTI0_IRQHandler(void) { if (外部中断挂起标志第0位 == 1) { 写1清除挂起标志; g_button_pressed = 1; } } int main(void) { while (1) { if (g_button_pressed) { g_button_pressed = 0; GPIO翻转LED引脚; } } }这里有几个细节我特别想强调。第一,g_button_pressed必须用volatile修饰,否则编译器可能把它优化到寄存器里,导致主循环感知不到变化。第二,标志位在中断里置1,在主循环里清0,这个“置位在中断、处理在主循环”的模式,是后续做复杂逻辑的基石。第三,ISR里尽量不要直接完成复杂流程,只记录事件,把耗时的活留给主循环。
3.4 扩展练习:定时器中断里的分频和重载计算
外部中断跑通之后,当天晚上我又把定时器中断过了一遍。定时器不会像按键那样需要一个物理信号才触发,它自己按照时钟数数,数到溢出时产生中断。
配置时无非是预分频器和重载值两个参数。预分频器把时钟频率降低,重载值决定一共数多少个周期触发一次。假设定时器输入时钟频率是72MHz,我想让中断频率为1Hz,那么可以先让预分频器设置为7199,即输入时钟被分成10kHz,再让重载值为9999,这样定时器计数10000次溢出一次,得到1Hz。
这套计算方法是通用的,虽然具体寄存器位宽不同,但思想一模一样。跑起来之后,LED以整秒频率闪烁,观察逻辑分析仪的波形,稳定周期就是1秒,说明计算没问题。定时器中断再配合外部中断,基本就覆盖了嵌入式里最常见的两类异步事件来源。
4. 常见问题与排查实录
4.1 中断不触发的三连排查法
第二天我又重新在板子上复现了一个“人家都能触发、我的就是不触发”的场景,排查过程非常典型。
第一步查时钟。我当时先入为主地以为GPIO时钟肯定开了,后来用调试器读时钟控制寄存器,发现外部中断复用的时钟位根本没使能。这提醒我,以后配置外设第一步不是写代码,而是先看手册里的时钟树。
第二步查引脚复用配置。外部中断功能往往是和GPIO复用的,不把引脚切到对应的功能模式,即使外部信号已经拉低,中断线也收不到边沿。
第三步查中断线和NVIC。I/O引脚对应的外部中断线编号不能错,比如引脚0只能映射到中断线0;同时NVIC对应通道的使能位必须置1。
我建议千万不要上来就怀疑芯片坏了,绝大多数中断不触发,都出在这三类的某一处。实际操作方法是拿调试器挂上,设几个断点:在ISR入口断点,在NVIC使能那一行断点,在GPIO配置后断点,看走到哪一步就不再往下走了,问题区域立刻缩小。
4.2 中断重复触发:抖动和挂起标志的“坑”
按键实验里我遇到过另一种现象:按一下键,LED状态翻转了好几次。第一反应是硬件没消抖:轻触按键在按下瞬间,簧片碰撞会有一连串的抖动脉冲,这些脉冲都能触发下降沿,于是ISR被反复执行。
解决思路很简单,但细节很多。不能直接在ISR里用延时消抖,因为ISR执行期间其他中断进不来,而且一次按键就卡住几十毫秒,等于是把中断的优点丢掉了。更好的做法是在ISR里记录一个时间戳,主循环判断两次触发间隔小于一定阈值就忽略;或者用定时器来做边沿检测后的稳定确认。
还有一个常见原因是挂起标志没清。有些外设的清标志方式比较特殊,我在实践笔记里特别写了:先确认清标志寄存器的属性,不要用赋值0的方式去清一个“写1清零”的位。我当时犯过这个错,现象就是中断响应得很奇怪,像抽风一样。
4.3 ISR里加延时的反例
这是我这天学到的非常深刻的一个教训。为了调试方便,有人会在中断服务函数里加一个短延时,让LED多亮一会儿,肉眼更容易看到现象。这个做法在小实验里看起来没问题,但它会带来一个隐藏风险:假如系统里还有一个高频定时器中断,按键中断进入ISR后如果长时间占用CPU,其他中断就得排队等。等我退出按键ISR,定时器中断已经积压了好几个事件,标志位虽然只能记住一次,但丢失的节拍已经补不回来。
我还尝试过在ISR里调用标准库里的延时函数,看起来能用,但本质上是在用系统稳定换几行代码的便利。后来我给自己定了个规矩:ISR里只做标记、按键状态记录、读取硬件数据这些极其轻量的事情。超过几十个周期的操作,一律放到主循环中完成。
这段经历写出来,是希望大家别重复踩这个坑。中断服务函数本质上是一个异步的入口,设计它的时候要像设计生产流水线一样,速度快、不堵塞,后面才可能承载更复杂的系统。
5. 学完中断之后,我的复盘与后续安排
5.1 中断驱动思维比“会配置中断”更重要
第二十三天结束之后,我复盘时最大的收获不是“我会配置外部中断了”,而是思维方式发生了变化。
以前写程序,总默认CPU应该在一段代码里从头跑到尾,碰到突发任务就靠轮询。有了中断之后,我脑子里开始有一个全新的框架:主循环负责“业务”,中断负责“事件”。外设什么时候来数据,我不知道;按键几点被按下,我也不知道;但我只需要提前告诉硬件“这个事件来了你插我一下”,然后放心做主流程的事。
写代码时开始习惯把“事件发生”和“事件处理”分开。比如串口接收中断里,只把数据放进缓冲区,后面主循环慢慢解析;按键中断只设置标志位,主循环再去处理单击双击。这种解耦让程序结构清爽很多,也正是以后项目规模变大之后唯一能走通的路。
5.2 后续练习计划:串口中断、环形缓冲区和状态机
中断跑通并不代表学完,反而意味着新的练习清单出来了。我暂时定了几件事:
第一,写一个串口接收中断配合环形缓冲区的Demo。目前我只会把串口数据直接打印出来,有了中断后,把接收数据放缓冲区、按行消息解析,是比点灯更实用的练习。第二,把按键做成单击、双击、长按都区分开的状态机。用外部中断触发记录时间戳,主循环根据时间间隔判断用户的手势。第三,把定时器中断扩展成软件定时器,做一个小的调度框架。有了这套东西之后,再加入下一个外设会轻松很多。
这些都不是新知识点,而是把第二十三天学到的中断能力应用到实际问题里。我突然觉得,自学嵌入式最棒的部分就在这个阶段:以前靠蛮力等待外设,现在可以靠事件驱动让系统自然流转。踩的坑不少,但每一步都算数。就我个人感受而言,中断这道坎值得多花时间,别急着赶进度,后面所有复杂的嵌入式系统,包括实时操作系统,都建立在这套“打断、保存、处理、恢复”的机制上。把这一天吃透,后面会顺畅得多。