STM32 临界区是什么:为什么有时候要用 __disable_irq() 保护变量
STM32 临界区是什么为什么有时候要用__disable_irq()保护变量前面写外部中断按键时我们遇到一个很容易让新手停住的东西__disable_irq()__enable_irq()很多人第一次看到它心里会嘀咕这是什么 为什么读一个按键事件还要关中断 关中断会不会很危险 不用行不行这些问题都很正常。因为从 LED、蜂鸣器、普通 GPIO 输入一路学过来我们写的大部分代码都是“主循环自己跑自己的”。到了外部中断这里程序突然变成了主循环在跑 中断也可能随时插进来这时候有些变量就不是一个地方在用而是主循环和中断都在用。这篇就专门把这个问题讲清楚什么是临界区 为什么主循环和中断共享变量时要小心 __disable_irq()到底保护了什么 什么时候该用什么时候不要乱用本篇不写新工程也不展开新代码。我们只把原理讲透。先从外部中断按键说起在外部中断按键那一篇里按键按下后大概会发生这样的流程按键电平变化 -EXTI 触发中断 -进入 HAL 回调 -记录“按键按下事件” -主循环发现这个事件 -翻转 LED这里最关键的不是 LED也不是 EXTI而是这句记录“按键按下事件”通常我们会用一个变量来表示这件事0没有按键事件1有按键按下事件中断里负责把它置为 1。主循环里负责读取它然后清成 0。听起来很简单对吧但问题也正是从这里开始的。一个变量被两个地方同时惦记我们先不看代码用一张表理解。|位置|会对事件变量做什么|| — | — ||中断回调|按键来了把事件变量置为 1||主循环|发现事件变量是 1处理它然后清成 0|这就叫共享变量它被两个执行环境共享主循环 中断注意“共享”本身不是问题。真正的问题是中断可能在主循环执行到一半的时候突然插进来也就是说主循环不是从头到尾安安静静执行完再轮到中断。中断是随时可能打断它的。这就像你正在记账你刚看到账本上写着有1笔待处理 正准备把它处理掉 突然有人又塞进来1笔新的 你没注意顺手把待处理标记清空了最后结果就是新来的那一笔被你清掉了在程序里这种现象就叫事件丢失。为什么“读一下再清零”也可能出问题很多新手会觉得我不就是读一下变量再把它清零吗 这么简单也会出问题会。原因是你看到的一行 C 语句到了 CPU 执行时往往不是一个不可拆开的动作。比如“读取事件、判断事件、清除事件”从逻辑上看是一件事。但 CPU 真正执行时它可能是几步读取变量 判断变量 准备清零 写回清零中断可能刚好插在这些步骤中间。举个时间线|时刻|发生了什么|| — | — ||1|主循环读取事件变量发现是 1||2|主循环准备处理这个事件||3|就在这时又来一次按键中断||4|中断把事件变量再次置为 1||5|中断结束回到主循环||6|主循环继续执行把事件变量清成 0|看起来每一步都合理。但最后结果是第二次按键事件丢了因为中断刚置好的 1被主循环后面的清零覆盖了。这就是临界区要解决的问题。临界区到底是什么临界区不是 STM32 独有的概念。它在单片机、RTOS、Linux、上位机多线程里都会出现。用最朴素的话说临界区就是一小段不能被打断的关键操作它通常有两个特点这段代码正在访问共享资源如果执行到一半被打断结果可能出错。在按键事件里共享资源就是按键事件变量主循环读取它、清除它。中断回调修改它。所以主循环在“读取并清除事件”的这一小段里需要确保不要刚读到一半中断又进来改同一个变量这段“读取并清除事件”的操作就是临界区。__disable_irq()做了什么__disable_irq()的作用可以先简单理解为临时禁止 CPU 响应普通中断对应的__enable_irq()就是重新允许 CPU 响应中断所以它们经常成对出现进入临界区先关中断 访问共享变量读、改、清 退出临界区再开中断它想保证的是这几步操作一口气做完中间不要被中断插队放回按键事件里就是主循环读取事件变量 主循环清除事件变量 这两个动作之间不允许按键中断插进来改同一个变量这样就能避免某些非常隐蔽的事件丢失。它不是为了“消抖”这里一定要分清楚。__disable_irq()不是用来消抖的。消抖解决的是机械按键刚按下/松开时电平会乱跳临界区解决的是主循环和中断同时访问同一个变量可能互相打断这两个问题经常出现在同一个按键工程里但它们不是一回事。可以这样记|问题|解决手段|| — | — ||按键电平抖动|延时确认、状态机、定时扫描||共享变量被打断|临界区、关中断、原子操作、队列|所以你在按键外部中断代码里看到__disable_irq()它保护的是事件变量不是过滤按键抖动。为什么不一直关中断既然关中断可以避免被打断那能不能在一大段代码前面关掉处理完再打开不建议。甚至可以说绝大多数情况下不要这么干。因为中断本来就是用来及时响应外部事件的。如果你关中断时间太长可能会出现这些问题串口接收不及时数据丢失定时器中断延后时间不准按键响应变慢PWM、ADC、DMA 回调被拖延系统里某些依赖中断的逻辑变得不稳定。所以临界区有一个很重要的原则关中断时间越短越好只保护真正需要“一口气完成”的几行操作。不要在关中断期间做这些事printf()HAL_Delay()等待某个外设完成复杂计算大量循环读写 Flash访问慢速通信外设。这些动作都可能耗时太久。临界区应该像过马路看准、快速通过、马上恢复交通不要站在路中间聊天。volatile能不能替代临界区很多人还会问共享变量加 volatile 不就行了吗不行。volatile和临界区解决的不是同一个问题。volatile主要是告诉编译器这个变量可能随时被别的地方改变 每次用它时都要真的去内存里读不要自作聪明缓存起来它解决的是编译器优化问题。但它不能保证读变量 判断变量 清变量这几步不会被中断打断。所以volatile 让你读到真实变化 临界区让关键读改操作不被插队两个概念都重要但不能互相替代。在主循环和中断共享变量时经常会同时看到变量用 volatile 修饰 读改清操作放进临界区这不是重复而是各管一件事。是不是所有共享变量都要关中断也不是。这就要看风险。如果中断只是设置一个标志主循环只是读取这个标志并且不清除、不修改那风险相对低。如果主循环要做“读完再清”就要小心。如果变量是多字节数据比如 16 位、32 位计数值在某些平台或某些访问方式下也要考虑读到一半被打断的问题。更典型的场景有|场景|为什么要小心|| — | — ||按键事件标志|中断置位主循环读后清零可能丢事件||串口接收计数|中断增加计数主循环读取和清理||定时器毫秒计数|中断更新主循环读取多字节值||环形缓冲区读写指针|中断写入主循环读取||ADC/DMA 完成标志|回调置位主循环处理后清除|判断要不要保护可以先问自己一句这个变量是不是会被中断和主循环同时读写如果答案是“是”再问第二句如果中断刚好插在中间会不会丢事件、读错值、覆盖状态如果答案还是“可能会”那就应该考虑临界区。除了关全局中断还有没有别的办法有。__disable_irq()是最直接、最容易理解的办法但不是唯一办法。随着工程复杂起来你会遇到更多选择|方法|适合场景|| — | — ||短时间关全局中断|裸机小工程保护很短的共享变量操作||只关闭某个外设中断|不想影响所有中断只保护某一路中断相关变量||使用计数而不是 0/1 标志|避免多个事件挤在一起只记成 1 次||环形缓冲区|串口、CAN、按键队列等多个事件缓存||RTOS 临界区|FreeRTOS 等系统里用系统提供的临界区接口||消息队列/事件组|任务和中断之间传递事件更清楚|新手阶段先掌握一个原则就够了裸机里保护几行共享变量操作可以用短临界区 工程变复杂后再考虑队列、缓冲区、RTOS 接口。不要一上来就把简单按键事件写成很复杂的框架。但也不要完全不知道临界区等到丢事件时才到处怀疑硬件。回到按键事件它到底保护了什么现在再回头看外部中断按键。中断回调里做的事很简单按键来了把事件标志置起来主循环里做的事也很简单看看有没有事件 如果有就拿走这个事件 顺手把标志清掉真正需要保护的就是“拿走并清掉”这一小段。为什么因为它必须保证我拿到的这个事件确实被我处理了 我清掉的这个标志不会误清掉中断刚刚新放进来的事件。所以临界区不是为了显得代码高级也不是固定套路。它是在告诉 CPU这几行我正在处理共享变量先别让中断插队。处理完马上恢复中断。这就是它的边界。新手最容易踩的 5 个坑1. 把临界区写得太大临界区只包住共享变量的关键操作。不要把 LED 控制、串口打印、延时等待都放进去。2. 以为volatile就万事大吉volatile能避免变量被编译器优化掉但不能保证多步操作不被中断打断。3. 忘了重新开中断关中断后一定要确保能重新打开。如果中间写了复杂逻辑、提前返回、错误分支就容易忘。所以临界区越短越不容易出错。4. 在中断里做太多事情中断里长时间运行会影响其他中断响应。按键中断里记录事件就够了具体业务放主循环。5. 用 0/1 标志记录高频事件如果事件来得很快0/1 标志只能表示“有过”不能表示“来了几次”。这时候要考虑计数、队列或缓冲区。按键这种低频事件用 0/1 标志通常够用串口数据这种高频事件就不太够了。本篇小结临界区不是一个神秘概念。它解决的就是一句话主循环和中断都要访问同一个变量时有几步关键操作不能被中断插队。__disable_irq()和__enable_irq()的作用就是临时把这几步包起来先暂停普通中断响应 快速处理共享变量 马上恢复中断响应你需要记住这几个判断__disable_irq()不是用来消抖的它保护的是主循环和中断共享的变量volatile不能替代临界区临界区要尽量短不要在临界区里打印、延时、等待外设中断里少做事主循环里做业务事件多了以后要考虑计数、队列或缓冲区。如果把外部中断按键那篇和这篇连起来看思路就很清楚了EXTI 负责发现按键变化 中断回调负责记录事件 临界区负责安全取走事件 主循环负责处理 LED 翻转这就是裸机 STM32 里非常常见的一种工程写法。下一次你在代码里看到__disable_irq()先别急着害怕也别随手删掉。先问一句这里是不是在保护一个主循环和中断共享的变量如果答案是那它大概率不是多余的。