
1. 中断到底是什么从一次“接电话”说起搞嵌入式开发的人几乎每天都要跟中断打交道。但很多人学中断的时候都是先背概念中断是指CPU在执行程序的过程中遇到紧急事件需要处理暂时中止当前程序的执行转而去处理紧急事件处理完后再回到原来被中断的地方继续执行。概念背得滚瓜烂熟一到写代码就懵——到底什么时候该用中断中断里该写什么不该写什么我见过不少刚入门的朋友写一个按键检测程序用的还是轮询。主循环里一遍一遍读GPIO电平读到低电平就认为按键按下。程序简单的时候没事一旦外设多起来传感器要读、屏幕要刷、电机要转轮询就撑不住了。CPU大部分时间都在空转按键响应慢不说功耗也压不下去。这时候就该换中断了。用生活里的事打比方轮询就像你在工位上不停抬头看门口看有没有人来找你没人来你也得看一上午啥也没干成。中断则是你正常干活电话铃响了才接电话接完接着干活。电话铃就是中断请求接电话就是中断服务函数挂掉电话回到刚才的工作就是中断返回。这个例子虽然朴素但把中断的核心逻辑全说透了。这篇文章想做的是把中断这个主题从头到尾捋一遍。从为什么需要中断、中断在硬件层面是怎么工作的到实际工程里怎么写中断服务函数、怎么配优先级、怎么排查死机问题我都会把实际项目中验证过的经验放进来。适合刚接触单片机的初学者也适合有几年经验但一直靠“背例程”写中断的朋友——看完你大概能明白中断背后那套机制到底是怎么回事。2. 为什么要用中断轮询和中断的账要算清楚2.1 轮询方式的致命问题先看一个最简单的场景。用STM32读一个温湿度传感器传感器每2秒准备好一次数据。用轮询的方式写主循环大概是这样的while (1) { if (sensor_data_ready()) { data read_sensor(); process_data(data); } // 其他任务 update_display(); handle_key(); control_motor(); }看起来没什么问题所有任务都能跑。但你仔细算一下时间账主循环每跑一圈sensor_data_ready()这个函数就被调用一次。如果其他任务里有一个是阻塞型的比如往OLED屏幕写一帧数据用了50毫秒那在这50毫秒里即使传感器数据已经准备好了程序也检测不到——必须等OLED写完了下一圈循环才能读到传感器状态。这就是轮询的第一问题实时性差。CPU被其他任务占住的时候紧急事件得不到及时处理。轮询的第二个问题是CPU利用率低。如果传感器2秒才准备好一次数据而主循环一圈只要1毫秒那意味着程序在2000次循环中有1999次都在做无用功——反复去查那个“数据准备好了吗”的标志位。CPU大量时间空转功耗自然降不下来。物联网设备靠电池供电每一微安的电流都要省轮询这种烧电的做法在低功耗场景下基本不可接受。2.2 中断解决了什么问题用中断改写上面的场景// 传感器数据准备好后通过一个引脚电平变化触发外部中断 void EXTI_IRQHandler(void) { sensor_data_ready_flag 1; // 只置一个标志位 } while (1) { if (sensor_data_ready_flag) { sensor_data_ready_flag 0; data read_sensor(); process_data(data); } // 其他任务正常跑不必担心错过传感器事件 update_display(); handle_key(); control_motor(); }区别很明显。传感器数据准备好的那一刻中断硬件会强制CPU暂停手头的事情跳去执行EXTI_IRQHandler把标志位置1然后回到原来的任务继续跑。主循环这边完全不用关心传感器什么时候好只要发现标志位被置1了去读数据就行。这个改动带来三个直接收益实时性提升从“事件发生”到“程序感知”的延迟从“主循环一圈的时间”缩短到“中断响应的硬件延迟”微秒级别。CPU利用率提高CPU不用再反复查探状态只在事件真正发生时被唤醒来处理其他时间专心干别的活。代码结构更清晰每个外设的事件处理逻辑从主循环里拆出来放到独立的中断服务函数中主循环保持简洁只做任务调度。2.3 是不是所有场景都要用中断也不是。中断虽然好但有自己的适用边界。比如一个普通按键如果你处理的是“按下后蜂鸣器响一声”这种对时间不敏感的操作用轮询完全够用甚至更简单。中断适合的是“事件不可预测、但发生时必须尽快处理”的场景外部信号触发、定时器溢出、串口收到数据、ADC转换完成、DMA传输结束……这些事件要么发生频率不确定要么错过就会丢数据必须用中断来兜底。还有一种情况不建议用中断事件发生的频率特别高。比如ADC连续采样1秒钟采100万个点每次都进中断的话CPU光是在中断进出上就要耗费大量时间主循环几乎跑不动。这种场景该用DMA而不是中断——DMA在后台搬运数据搬完一整批才触发一次中断通知CPU来取。做技术选型时要先算清这笔账事件频率、处理耗时、CPU负载、功耗预算。四个人坐在一起把账算明白该用中断还是DMA还是轮询自然就清楚了。3. 中断系统是怎么运转的硬件层面那些事3.1 中断向量表一张电话分机表单片机内部有一张表叫中断向量表。这张表存放在Flash的固定地址Cortex-M内核通常从0x00000000开始每个中断源对应表中的一个表项表项里存放的是该中断的服务函数地址。形象一点说中断向量表就像是公司的电话分机表你拨分机号系统就知道该转给哪个部门。CPU收到中断请求后会根据中断号去查这张表找到对应服务函数的入口地址跳过去执行。以STM32F103为例中断向量表的长相大致如下部分向量位置中断号外设说明0x00-初始SP值栈顶地址0x04-Reset_Handler复位后第一条指令0x08-NMI_Handler不可屏蔽中断0x0C-HardFault_Handler硬件错误0x406EXTI0_IRQHandler外部中断00x5811USART1_IRQHandler串口1中断............在C语言工程里这些服务函数的名字通常是固定的——由启动文件startup.s里的中断向量表定义好你只需要在C代码里定义同名函数链接器就会自动把你写的函数地址填到向量表里。所以如果你写了一个USART1_IRQHandler但实际上把它名字拼错了比如写成USART1_IRQ_Handler编译不会报错但中断触发后CPU查向量表找不到你的函数会跳到一个默认的死循环——这通常就是“中断没反应”的排查方向之一。3.2 从中断请求到中断返回完整流程拆解以Cortex-M3为例一次中断的完整处理流程可以分成以下几个阶段第一阶段请求与响应外设产生中断事件后通过中断控制器STM32上叫NVICNested Vectored Interrupt Controller嵌套向量中断控制器向CPU发出请求。NVIC不只是个“传话筒”它还负责管理中断的使能、挂起、优先级仲裁等事务。CPU在每个指令周期的边界检查是否有更高优先级的中断在等待如果有就会暂停当前指令流进入中断响应流程。第二阶段压栈CPU自动把当前正在执行的程序的上下文保存到栈里。对于Cortex-M这一过程是硬件自动完成的——依次压入PC、xPSR、R0-R3、R12、LR这些寄存器。这个过程不需要软件干预压栈速度快且可靠这也是Cortex-M的一大优势。第三阶段取向量并跳转CPU根据中断号去中断向量表里取出对应的服务函数地址跳转过去执行。同时硬件会自动把LR寄存器设置成一个特殊值EXC_RETURN这个值记录了中断返回时需要恢复的状态信息。第四阶段执行中断服务函数你的C代码在这里运行。此时CPU处于Handler模式处理模式与普通线程模式不同——具体差异在下面的优先级部分会展开。第五阶段中断返回中断服务函数执行完毕后执行BX LR指令。CPU看到LR的值是EXC_RETURN格式就知道这是要从中断返回于是自动从栈里恢复之前保存的寄存器值回到被中断打断的指令继续执行。这套流程最关键的信息是压栈和出栈都是硬件完成的不需要你在中断服务函数里写任何保存现场的代码。这比老式的51单片机要友好得多。51单片机需要程序员自己在中断里用PUSH/POP手动保存ACC、PSW等寄存器一个不小心就出bug。Cortex-M直接把这件事自动化了这也是为什么现代ARM内核的的中断用起来“开箱即用”的原因。3.3 中断标志位用完必须清理的“已读回执”每个中断源都有一个或几个标志位用来记录“事件是否已经发生”。比如串口发送完成标志位、外部中断状态标志位、定时器更新标志位。进入中断服务函数后第一件要做的事通常是清除产生本次中断的标志位。为什么要清拿外部中断举例。GPIO引脚检测到下降沿后EXTI的挂起寄存器PR里对应的bit会被硬件置1同时向NVIC发出中断请求。如果你在中断服务函数里不把这个bit清掉CPU处理完本次中断后硬件检查挂起寄存器时发现标志位还是1会再次触发相同的中断——于是你陷入了一个无限中断的死循环。主程序永远跑不出来表现为“程序卡死了”。清除标志位的方式因外设而异常见的有这么几类写1清除W1CWrite 1 to Clear向标志位写1把它清零。这种方式的优点是即使你误操作也不会误清别的标志位因为只有写1的那一位被清零写0无效。EXTI的PR寄存器、DMA的中断状态寄存器都是这个模式。读清除比如串口接收数据寄存器你只要读完数据寄存器的内容接收标志位就会自动清零。软件清零有些标志位需要先写0再写1或者按特定序列操作才能清除定时器更新标志位在部分芯片上需要这种操作。实际工程中一个常见的坑是多个中断共享同一个服务函数比如串口1的接收和发送中断共用一个USART1_IRQHandler。这时候如果只清了接收标志位没清发送完成标志位就会导致发送完成中断一直挂着反复触发中断。所以共享服务函数里所有可能触发本中断的中断源标志位都要处理干净——用哪个清哪个没用到的也要防一手。4. 中断优先级多任务碰撞时的仲裁规则4.1 优先级分组抢占优先级与子优先级当两个中断同时到来CPU该先处理谁答案是看优先级。Cortex-M的NVIC支持两级优先级概念抢占优先级preemption priority和子优先级subpriority。抢占优先级决定一个中断能否打断正在执行的另一个中断。抢占优先级数值越小优先级越高。这是中断嵌套的关键。子优先级只决定当两个抢占优先级相同的中断同时发生时谁先执行。子优先级不能引发打断只能排队等候。同样数值越小优先级越高。在STM32上这两级优先级是怎么分配bit的由NVIC_PriorityGroupConfig来决定。比如设置为NVIC_PriorityGroup_2就是抢占优先级用2个bit取值范围0~3子优先级用2个bit取值范围0~3。全部4个bit加起来一共16级Group_0时16级子优先级Group_4时16级抢占优先级。这里有个非常容易踩的坑把两个中断分成不同抢占优先级、相同子优先级时改变分组方式会让原本的优先级关系全部失效。很多人在工程初期没规划好分组后面发现某个中断总是不响应到处查代码结果最后发现是优先级分组配置的问题。我的建议是项目开始第一行就配置好分组方式整个工程保持不变NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)是绝大多数场景的稳妥选择。4.2 如何给中断安排合理的优先级优先级分配没有标准答案但有一条经验法则高实时性、短执行时间的中断分配最高的抢占优先级长耗时、低实时性的任务用低优先级或者干脆丢到主循环里处理。举个例子一个典型的嵌入式系统中断源抢占优先级子优先级理由系统心跳定时器00系统时基必须准时不能被打断串口接收10数据不能丢但允许极短延迟外部IO触发20快速响应但可以容忍几个微秒延迟低优先级定时任务30慢速任务可以被任何中断打断这里有个反直觉的地方系统心跳定时器不能中断服务函数的执行。假设串口正在接收一帧数据此时心跳定时器触发了中断如果它的优先级比串口低那么串口接收会先执行完心跳中断才会得到响应——这就会导致时基不准确。但如果把心跳定时器优先级设最高那它每次触发都会打断正在执行的中断服务函数中断服务函数被一分为二执行时间被拉长。这中间的平衡要自己把握没有绝对正确的答案。4.3 中断嵌套的利与弊支持中断嵌套是Cortex-M的一大卖点。高优先级中断可以打断低优先级中断CPU先去处理更紧急的事处理完再回来处理原来的中断。但中断嵌套用不好会带来两个问题**第一栈深度不可控。**每次中断嵌套都意味着额外的压栈。如果嵌套层级太深加上主程序的栈使用栈空间可能溢出程序随机死机。所以引入高抢占优先级的中断前一定要评估栈空间余量。**第二资源共享竞争。**两个中断服务函数如果同时访问同一个全局变量或同一个外设寄存器就可能出现数据不一致的问题。比如主循环在读取一个中断服务函数更新的变量如果主循环正在读一半中断触发改变了这个变量的另一个字段主循环拿到的数据就是“撕裂”的。经典解决办法是在访问共享数据时暂时关闭中断或者用专用的原子操作指令。我对中断嵌套的实践建议是默认情况下尽量减少嵌套层级优先用子优先级做排队。中断服务函数里该干的事尽快干完复杂逻辑都丢到主循环去。嵌套多一层风险多一层。5. 中断服务函数的黄金法则能短则短能轻则轻5.1 为什么中断服务函数要短很多初学者写中断喜欢把业务逻辑直接堆在中断服务函数里。比如串口收到一帧数据直接在中断里解析协议、校验CRC、把数据显示到屏幕上。看起来方便实际上是给自己埋雷。原因有三层阻塞主程序。中断服务函数执行期间主循环是完全停摆的。你在中断里刷屏幕万一刷屏时间太长可能就错过了其他中断事件。抢占其他中断。如果中断服务函数执行期间有更高优先级的中断到来会打断执行。如果更低优先级的中断则会被长时间阻塞可能丢数据。调试困难。中断里出bug问题极其隐蔽——它在任意时刻都可能被打断、被插入用调试器断点时可能永远都停不到你想看的那一行。所以业界有一条成文的“黄金法则”中断服务函数里只做最必要的事其余全部放到主循环去做。必要的事包括读取硬件寄存器存到局部变量或全局变量、清除中断标志位、置一个“事件发生”的标志位。所有耗时操作——数据处理、协议解析、外设操作、显示刷新——一律搬到主循环。5.2 中断与主循环之间如何传数据中断负责采集事件主循环负责处理事件两者之间的数据交换需要一个安全通道。最简单的方案是全局变量标志位volatile uint8_t rx_flag 0; volatile uint8_t rx_data[64]; volatile uint8_t rx_len 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { rx_data[rx_len] USART_ReceiveData(USART1); if (rx_len 64) { rx_len 0; // 防止缓冲区溢出 } rx_flag 1; USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }注意这里全局变量的声明用了volatile关键字。为什么必须加因为C编译器默认认为变量的值只由代码本身修改。如果在优化等级较高时编译器可能把rx_flag的值缓存到CPU寄存器里主循环每次读取都直接拿寄存器里的旧值而不是重新从内存读取——这样就永远看不到中断里对rx_flag的修改。加上volatile后编译器会强制每次读写都访问内存地址避免这个优化陷阱。这是嵌入式开发中一个非常经典、又容易忽略的坑。对于更复杂的数据传输比如一帧结构体数据更好的做法是设计一个环形缓冲区ring buffer。中断往缓冲区尾部写数据主循环从头部读数据双方通过读写索引协调。环形缓冲区可以在无锁的情况下实现单生产者单消费者的安全通信。这是嵌入式系统里使用频率最高的数据结构之一建议每个工程师都手写一遍理解它的原理再放进项目里。5.3 临界区保护共享数据的最后防线有时候主循环和中断服务函数会访问同一个变量并且这个变量的更新需要“读-改-写”三步。如果中断在“改”的中间插入进来就会出问题。比如volatile uint32_t tick_count 0; // 主循环中 if (tick_count 100) { tick_count - 100; do_periodic_task(); }这段代码问题在哪里假设主循环判定了tick_count是150准备执行减法。执行到一半定时器中断触发了tick_count变成了180。等中断返回主循环继续执行减法tick_count变成80——但实际上应该是180-10080OK这个例子碰巧没出问题。换一种情况如果主循环判定tick_count是50已经确认不满足大于等于100的条件但在这判断之后、进入else分支之前中断把tick_count变成了150——那么主循环会走错误的分支漏掉了本该执行的任务。这就是典型的竞态条件。解决办法是在访问共享变量时暂时屏蔽中断这就是临界区__disable_irq(); if (tick_count 100) { tick_count - 100; do_periodic_task(); } __enable_irq();__disable_irq()和__enable_irq()是CMSIS提供的接口可以在不开中断的情况下执行一段关键代码。但注意临界区里不能执行耗时操作。你关中断的时间越长系统的实时性越差。正确姿势是在临界区里只做“读取变量-修改变量-写回变量”的极简操作一秒钟不到就恢复中断。6. 从零配置一个中断以按键外部中断为例6.1 硬件与需求先明确一个具体的项目场景用STM32F103的PA0引脚接一个按键按键另一端接地。按下按键时PA0产生一个下降沿触发外部中断中断服务函数里翻转一个LED的状态。这个例子麻雀虽小五脏俱全包含了外部中断的全部配置要素。硬件连接关键点PA0默认接按键。平时PA0内部上拉读到高电平按下按键后PA0被拉到地读到低电平。配置时要用GPIO_Mode_IPD还是GPIO_Mode_IPU这里需要下拉还是上拉实际上按键接地应该配上拉输入——平时维持高电平按下时变低。用内部上拉就不用外接上拉电阻了。6.2 完整配置代码与逐行解释// 1. 开启GPIOA和AFIO时钟EXTI需要AFIO复用 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // 2. 配置PA0为上拉输入 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 把PA0连接到EXTI0线上 GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); // 4. 配置EXTI0下降沿触发、使能中断 EXTI_InitTypeDef EXTI_InitStructure; EXTI_InitStructure.EXTI_Line EXTI_Line0; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); // 5. 配置NVIC NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure);这段代码里有几个细节值得说明。第一为什么需要开AFIO时钟。EXTI的输入线是复用的。每个EXTI线可以由多个引脚的其中一个接入比如EXTI0线可以接PA0、PB0、PC0……具体接哪个由AFIO的配置寄存器决定。要读写这个寄存器必须先打开AFIO时钟。很多人配置外部中断时忘了这一步结果配置写不进去中断根本不触发。第二EXTI_Trigger_Falling 只触发下降沿。按键按下时从高到低产生下降沿触发一次中断。松开按键时从低到高是上升沿不会触发——因为前面配置只认下降沿。第三NVIC_IRQChannel 对应中断服务函数名。这里配置的是EXTI0_IRQn那么中断服务函数名就必须是EXTI0_IRQHandler两者对应关系固定在启动文件里。中断服务函数写如下void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { // 处理按键事件翻转LED GPIO_WriteBit(GPIOB, GPIO_Pin_0, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOB, GPIO_Pin_0))); // 清除中断标志位防止重复触发 EXTI_ClearITPendingBit(EXTI_Line0); } }这段代码有一个实际问题没有消抖。机械按键在按下和松开的瞬间触点会弹跳产生一串抖动脉冲每个脉冲都可能触发一次下降沿中断。如果你直接翻转LED按下一次可能翻转了五六次观察到的现象是LED灯“闪了好几下”。6.3 按键消抖的多种实现方案消抖的办法多得很最简单的方案是延时消抖void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { Delay_ms(10); // 避开抖动窗口 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 0) { // 确认按键确实按下 GPIO_WriteBit(GPIOB, GPIO_Pin_0, ...); } EXTI_ClearITPendingBit(EXTI_Line0); } }但这个方法不推荐在生产项目中用。为什么因为Delay_ms(10)是在中断服务函数里执行的阻塞延时会占用整个CPU长达10毫秒——在这10毫秒内其他中断都无法响应实时性大打折扣。更好的做法是中断里只置标志位消抖逻辑放到主循环。按下按键后中断置一个key_pressed_flag主循环检测到标志位后延时10毫秒再次读取引脚电平确认电平稳定后才认为按键有效。这样做避免了中断阻塞又达到了消抖的效果。还有一种思路是用定时器做“按键松开才处理”检测到下降沿后启动一个10~20毫秒的单次定时器定时器中断里再检测引脚电平确认没有恢复高电平才判定为有效按下。这种方案在消抖的同时还能精确判断按键时长适合做短按/长按功能的产品。7. 中断系统的常见问题排查我踩过的那些坑7.1 中断不触发的排查清单中断不触发是嵌入式开发中最高频的问题之一。排查时我习惯按照下面的清单从上往下过排查项说明时钟是否开启外设时钟没使能寄存器写不进去外设根本不在工作GPIO模式是否配对外部中断要用输入模式配成推挽输出就白干了AFIO配置是否正确EXTI线映射到正确的端口对应引脚才能接入EXTI中断线使能是否忘记EXTI_LineCmd 必须为 ENABLE否则事件无法传到NVICNVIC配置是否使能NVIC_IRQChannelCmd 必须为 ENABLE否则即使EXTI触发也不会进中断中断服务函数名是否拼写正确名字拼错链接器不会报错但中断永远不会进入你的函数是否被更高级别的中断长期阻塞如果有一个高优先级中断长时间占用CPU低优先级中断可能根本得不到响应主程序是否进入死循环有时候中断其实触发了但主程序自己卡死了观察不到正确现象其中“服务函数名拼写错误”这个坑我踩过不止一次。最邪门的一次是工程里同时用了标准库和HAL库的代码两个库的启动文件都定义了同名的SysTick_Handler链接器没报错但把其中一个覆盖了结果系统时基彻底乱掉。排查了很久才找到原因。7.2 中断反复进入导致死机的排查思路程序看起来“卡死”了用调试器暂停后发现CPU正停在中断服务函数里而且反复执行同一个地址的指令——这是典型的中断标志位没清导致的死循环。排查步骤在中断服务函数入口处打断点看是否反复进入。检查所有可能触发该中断的标志位是否都已清除。特别注意共享中断服务函数的情况——比如串口发送完成和接收完成共用同一个IRQHandler两个标志位都要检查。如果标志位清除方式不对比如该写1清除却写了0同样会造成反复触发。另外还有一种隐蔽场景中断服务函数里调用了某个函数这个函数内部也操作了产生中断的外设导致标志位被意外重新置位。这种问题需要仔细审查中断里调用的每个函数——这也是我强调“中断里少调函数”的原因之一。7.3 中断被高优先级任务堵死的场景设想一个系统串口接收中断的抢占优先级是1定时器中断的抢占优先级是0。如果定时器中断触发特别频繁比如1毫秒一次且服务函数执行时间接近1毫秒那么串口接收中断会一直被定时器中断打断或推迟造成数据丢失。这时候的排查方式不是改代码本身而是先测量用示波器或逻辑分析仪测量一个GPIO引脚在串口中断里翻转的波形看看翻转是否规律再测定时器中断的执行时间。两个时间数据摆出来就能确定是谁堵了谁。解决思路通常有几种提高串口中断的优先级缩短定时器中断的执行时间把多余的计算移到主循环或者干脆把定时器中断改为主循环轮询把时基让给更高实时性的任务。具体怎么选要看系统的实时性需求。7.4 浮点运算与中断上下文的特殊问题如果芯片带FPU浮点单元比如STM32F4系列中断处理还有一个隐藏的地雷FPU寄存器是否需要额外保存。Cortex-M4的FPU有32个单精度寄存器S0-S31。默认情况下中断压栈只保存核心寄存器FPU寄存器要不要保存由FPCCR寄存器的LSPEN位决定。如果配置错误中断服务函数里用到浮点运算时FPU寄存器被破坏主循环里的浮点计算就会得出错误结果——这种bug极难排查因为它不是必现的跟中断触发的时机有关。我的建议是除非确有必要否则不要在中断服务函数里做浮点运算。浮点计算又慢又容易踩上下文保存的坑能用整数实现就用整数实在要用浮点确认芯片的FPU上下文切换被正确开启了。8. 一个实战案例多外设中断的协同设计8.1 系统需求概览把这套知识串起来看一个综合案例。项目背景一个小型环境监测设备需求包括——1路温湿度传感器每5秒采集一次1路气压传感器每10秒采集一次1路RS485串口以9600波特率接收上位机的控制指令1路按键输入按下时切换显示模式OLED屏幕显示数据每秒刷新一次电池供电要求低功耗。8.2 中断资源的分配方案按照前面讲的经验中断分配方案如下外设中断源抢占优先级子优先级触发时机定时器TIM2更新中断001秒时基驱动系统时钟串口USART1接收中断10上位机发来数据时按键EXTI0外部中断20按键按下传感器无独立中断--由定时器触发周期性采集这个方案的设计思路TIM2的1秒时基是全局心跳优先级最高驱动所有周期性任务串口接收优先级次之因为串口数据不来不知道什么时候来来晚了可能丢帧按键优先级最低延迟几个毫秒处理完全没影响。8.3 中断服务函数与主循环的分工中断服务函数设计如下TIM2中断维护system_tick计数器每隔5秒置sensor_temp_humidity_flag每隔10秒置sensor_pressure_flag。这些标志位只是设置主循环检测到后读取传感器。USART1中断接收到的字节写入环形缓冲区置uart_frame_available_flag主循环去解析帧。EXTI0中断置key_pressed_flag不做消抖消抖在主循环完成。主循环的结构while (1) { if (sensor_temp_humidity_flag) { sensor_temp_humidity_flag 0; read_temp_humidity(); } if (sensor_pressure_flag) { sensor_pressure_flag 0; read_pressure(); } if (uart_frame_available_flag) { uart_frame_available_flag 0; parse_uart_frame(); } if (key_pressed_flag) { key_pressed_flag 0; delay_ms(20); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 0) { switch_display_mode(); } } update_display(); enter_low_power(); }这个结构的核心思想是中断服务函数只负责“通知”主循环负责“处理”。中断响应速度极快微秒级业务逻辑处理时间灵活可以在几个毫秒内完成两边都达到最优状态。8.4 低功耗设计与中断的配合低功耗是这个案例里最需要动脑的部分。单片机进入睡眠模式后CPU停止运行主循环也停了。这时候怎么唤醒靠中断。STM32的睡眠模式下所有NVIC中断都能唤醒CPU。但要注意唤醒后程序从哪继续执行取决于睡眠指令。使用WFI等待中断指令时CPU执行完中断服务函数后回到被中断的指令继续执行相当于一次普通中断。使用WFE等待事件指令时行为略有差异需要额外的事件机制配合。实际设计中低功耗模式下的时基保持是个关键点。如果芯片进入停止模式定时器停止系统时基就断了。这时要么使用RTC实时时钟维持时基要么定期唤醒CPU刷新时基。常见做法是RTC定时唤醒CPUCPU检查事件队列处理完毕后再次进入睡眠。中断服务函数负责把唤醒事件记录下来主循环在唤醒后统一处理。这套机制被称为“事件驱动低功耗”架构是物联网终端设备的标配。这里有几个实践要点进入睡眠前把不需要的中断关掉减少误唤醒中断服务函数里尽量少做操作——低功耗模式下CPU反而更“忙”因为每次唤醒都要经历完整的时钟稳定周期时间开销比正常运行时大得多如果串口是唤醒源之一要考虑串口的空闲中断和地址匹配唤醒功能避免每个字节都唤醒CPU一次。9. 关于中断的最后一课经验与建议做了这么多年嵌入式踩了无数关于中断的坑我总结出几条实用的经验分享给正在看这篇文章的朋友。**第一条建议中断服务函数的代码行数要控制在10行以内。**给个项目里其他同事立个规矩中断里只做“读硬件、保存数据、清标志、置标志”这四件事。超过10行的逻辑放到主循环。如果主循环太忙就增加一个“任务队列”中断置位的事件由调度器统一处理保证每个事件都不会被饿死。**第二条建议所有在中断里修改的全局变量必须用 volatile 声明。**这条不是建议是强制要求。在写工程前先规定好编码规范中断相关变量必须以_flag结尾或统一放在单独的头文件里养成习惯后这类bug基本绝迹。**第三条建议优先级分组一定提前定好写进项目启动代码全工程统一。**中途改分组方式会导致所有中断的优先级关系重新洗牌是埋雷行为。如果真的需要调整优先级停下手头所有工作先把所有NVIC配置全部审查一遍再动手。**第四条建议学会用逻辑分析仪或示波器测量中断响应时间。**写一个测试函数在中断服务函数入口翻转一个GPIO引脚出口再翻转回来。用示波器测量脉冲宽度就是该中断的服务函数执行时间。把这个时间和你的实时性指标对照——如果超过指标的一半就要考虑优化了。这招在排查实时性问题时极其有效。**第五条建议写中断服务函数时默认不调用任何库函数。**尤其是printf、Delay这类函数。如果你确实需要在中断里输出调试信息用串口直接写寄存器发送不要调用标准库的串口函数——标准库函数往往做了很多额外处理而且可能是非可重入的。可重入性问题是一个大坑我在实际项目里遇到过两次都是因为中断里调用了非可重入函数导致数据错乱调试了很久才定位到是函数重入问题。回到开头那个接电话的比喻。优秀的工程师接电话时会先记下来电内容告诉对方“稍后回电”然后挂掉电话继续干手头的活等手头的活告一段落再处理来电记录。差劲的工程师接起电话就在电话里处理所有事情结果电话一个接一个手头的活永远干不完。中断的设计框架本质上就是这样的时间管理哲学——把你处理事件的时间切成两段紧急响应段中断服务函数和从容处理段主循环。把这两段分清楚整个系统的稳定性、实时性和可维护性就都上了台阶。