ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

中断、关中断与开中断:临界区、延迟与多平台实战

中断、关中断与开中断:临界区、延迟与多平台实战 1. 中断先用一句话讲清楚它是 CPU 的一次有事喊我几年前带一个做数据采集的同事排查过一个很典型的问题设备空闲时串口收数据一切正常可只要把定时器中断打开接收缓冲区里就会零星少几个字节偶尔还多出一个重复值。代码读起来没有任何毛病接收也确实用了标准的中断服务函数。最后定位到的原因朴素得有点扎心——定时器中断里有一小段临界区关着中断没及时开回来把串口的接收窗口给盖住了。这件事让我意识到中断、关中断、开中断这三个词大家天天挂在嘴边可真要追问一句你关的到底是谁的中断、关了多久、什么时候必须开回来,能答清楚的人其实不多。这篇内容就想把这三个概念从最底层捋一遍重点讲透关中断和开中断背后的动机与代价以及在 x86、ARM Cortex-M、Linux 这些平台上究竟该怎么写。不管你是正在啃计算机操作系统原理的学生还是写裸机驱动、调 stm32 中断的嵌入式工程师或者只是被linux 中断中断服务函数这类问题绊住过下面这些内容应该都能对得上号。1.1 从轮询到中断CPU 的时间到底被谁浪费了理解中断最省事的办法是先看没有中断会怎样。CPU 想知道一个按键有没有被按下、串口有没有收到数据只能不停地去读寄存器这种办法叫轮询polling。轮询的问题不在于读这个动作本身而在于它把 CPU 死死地绑在了守株待兔上按键可能几个小时才按一次可 CPU 为了不漏掉那一次就得一直盯着。设备一多轮询的循环就越拉越长响应一个事件的延迟也随之上升——你盯着十个设备轮流问一圈每个设备实际被检查的间隔就是这一圈的时间。中断把这件事彻底反了过来。外设不再等 CPU 来问而是自己主动举手数据到了、按键按下了、定时器数满了就通过一根专门的中断请求线IRQ拉一下电平告诉中断控制器我有事。CPU 正常执行主程序收到这个信号后才暂停手头的活跳过去处理处理完再回来接着干。这里有个容易被忽略的点中断不是让 CPU 变快了而是让 CPU 把等待这段时间腾出来干别的。主循环里可以放心地去做计算、刷新屏幕不用再担心漏掉串口的一个字节。所以判断一个场景该不该用中断标准很简单——如果这件事发生的时刻不可预测、但要求及时响应那就该用中断如果它一定会按固定节奏来且对延迟不敏感轮询反而更简单、更好调试。1.2 硬件中断、软件中断和异常走的是同一条通路很多教材把硬件中断、软件中断、异常拆成三块讲容易让人以为它们是三套独立机制。实际在 CPU 眼里它们走的是同一扇门区别只在于谁来敲门。硬件中断是外设发起的比如串口接收完成、定时器溢出、按键电平变化它们经中断控制器汇总后送到 CPU 的中断引脚软件中断是程序主动发起的最典型的就是系统调用——在 x86 上是一条int 0x80或syscall指令在 ARM 上是一条svc老架构写作swi指令程序执行它就是在假装自己是个外设请求 CPU 切换到内核态去做点特权操作异常则是 CPU 在执行指令过程中自己发现的问题比如除零、缺页、非法指令它和中断最本质的差别是中断发生在两条指令之间异常往往发生在某条指令内部而且异常处理完可能还要重新执行那条出错的指令。把它们放在一起看有个好处你会明白为什么操作系统内核里处理缺页和系统调用的入口在写法上如此相似——它们都要保存现场、切栈、进入内核例程、再恢复。理解了这一点再看软件中断和硬件中断这类面试题就不再是背概念而是能说清楚它们共享了哪套保存与恢复的框架。1.3 中断向量表与中断服务函数CPU 凭什么知道跳到哪中断来了CPU 得知道该跳去哪段代码答案就是中断向量表。它本质上是一张放在固定地址的表每一项存着一个函数入口地址下标就是中断号。x86 里这张表叫 IDT中断描述符表位置由 IDTR 寄存器给出每一项除了入口地址还带权限位Cortex-M 里它更直接就是一块从 0x00000000 开始的地址数组第一项是初始栈指针第二项是复位向量后面依次排着各种中断向量运行时还可以通过 VTOR 寄存器把这整张表搬到别处。所谓中断服务函数ISR就是这张表里某个入口指向的那段代码在 stm32 的启动文件里你看到的xxx_IRQHandler就是它。拿 stm32 的中断配置举个例子你想让某个引脚上的按键触发中断大致要做这几件事——打开对应 GPIO 的时钟把引脚配成输入并选好触发边沿上升沿、下降沿还是双边沿使能 EXTI 线上对应的中断屏蔽位最后在 NVIC 里把这个中断通道的使能位置 1、设好优先级同时把向量表里对应的EXTIx_IRQHandler实现出来。这一整套动作合起来就是常说的中断配置。我见过不少人卡在中断函数写了但进不去检查下来十有八九是两件事没做NVIC 里那条中断通道压根没使能或者函数名和启动文件里定义的名字对不上——名字对不上你写的就是一个普通函数自然不会挂到向量表上。2. 关中断为什么存在一个自增操作就能说明白讲完了中断是什么接下来要回答一个听起来很怪的问题好端端的中断为什么要关掉很多人第一次听到关中断会本能地觉得这是反操作——中断不是好东西吗关它干嘛。答案藏在并发里。只要有两个执行流一个是主程序一个是中断服务函数会碰同一份数据就存在被打断后数据处于半成品状态的窗口而关中断就是把这个窗口暂时封死的手段。下面用最经典的一个例子把它讲透。2.1 一句 counter 被编译器拆成了三条指令在 C 里写counter看着像一个原子动作可编译器把它翻译成汇编后通常是三条指令先把counter从内存读进寄存器Load在寄存器里加一Add再写回内存Store。假设counter初值是 5主程序刚执行完 Load寄存器里是 5还没来得及写回这时一个中断来了。中断服务函数里也对同一个counter做了自增它完整地跑完了 Load 5、Add 6、Store 6内存里现在是 6。中断返回主程序拿着寄存器里那个旧值 5 继续 Add 得到 6Store 回内存——内存里还是 6。两次自增结果只加了一次。这就是所谓竞态race condition它的可怕之处在于偶发绝大多数时候你跑一天都不会出错一旦时序凑巧撞上就丢一次数据而且完全不报错。调试时你加打印、加断点时序一改变问题就消失了于是陷入改一改好像好了、过几天又冒出来的循环。对付它的标准手段就是在 Load 和 Store 之间不让中断插进来——把这三条指令包进一个临界区critical section进入前关中断出来后开中断。这样中断服务函数要么在这段代码之前跑完要么等它跑完再跑绝不会卡在中间。2.2 关中断守住的到底是什么边界理解临界区关键在于想清楚我要保护的是哪一段不变式。上面那个例子里不变式是counter的值必须能反映每一次自增所以保护范围就是读-改-写这三条指令一个字节都不能多。实际工程里常见的不变式还有几类一个链表的head指针和tail指针必须同时更新不能让中断看到只有head变了的中间状态一个状态机的主状态和子状态必须成套修改一个环形缓冲区的读写指针必须保持一致。判断临界区边界的经验法则是找出所有会被中断和主程序同时访问的共享变量围着它们的每一次非原子修改划范围。这里有个坑我踩过不止一次有人图省事把整个处理流程都用关中断包起来——从读取外设寄存器、解析协议、计算校验一直到存进缓冲区。结果临界区长达几百微秒中断延迟被拉得老高串口开始丢字节看门狗都差点被喂不上。正确的做法恰恰相反临界区要尽可能短只包住那几条真正需要原子的指令耗时的解析、计算全部挪到关中断之外做。记住一句话——关中断是为了保护数据不是为了保护逻辑。2.3 关中断的代价要用延迟来支付关中断不是免费的它的代价是**中断延迟interrupt latency**的增加。在关中断期间所有被屏蔽的中断都不会被响应它们只能排队等着直到你开中断。如果这段时间里外设需要及时响应而它又没有硬件缓冲数据就会直接丢。最典型的就是 UART接收寄存器通常只有一个字节的缓冲当前字节还没被读走、下一个字节就到了硬件就会置溢出标志并把新字节丢掉。这也是为什么高速串口在关中断时间偏长的系统里特别容易掉数据。代价还不止丢数据。中断延迟变大会直接影响实时性电机控制的电流环要求在一个 PWM 周期内完成采样和计算你中途关中断拖了几十微秒控制就可能失稳音频采集要求每个采样周期按时取数延迟一抖就听到爆音。所以关中断这件事要当成借钱来看待——能借但要清楚利息延迟有多高、什么时候必须还。有经验的做法是给自己定个硬指标比如任何临界区关中断时间不超过 10 微秒然后用示波器去实测验证而不是凭感觉认为我这段很快。3. 开中断不是再把开关拨回去这么简单很多人把关中断和开中断想成一对对称的操作关下去、用完再打开一开一关回到原点。真实情况比这复杂得多。开中断时要考虑开回到什么程度——是恢复到关之前的原样还是无条件全开要考虑当时是不是身处另一个更外层的临界区还要考虑在多核系统里你关的这一下到底关住了谁。这一节把这几个层次拆开说。3.1 允许嵌套和不允许嵌套是两种中断模型先区分两个概念中断嵌套和临界区嵌套。中断嵌套说的是高优先级中断能不能打断正在执行的低优先级中断服务函数。有些系统是不允许嵌套的一个中断进来后CPU 自动屏蔽同级及更低优先级的中断等这个服务函数整段跑完才放开另一些系统允许嵌套高优先级可以随时抢占低优先级响应更快但代码复杂度也上去了——同一个共享变量的访问顺序变得更难预测栈的深度也不再是常数。Cortex-M 的做法比较巧妙它的优先级是硬件仲裁的只要两个中断优先级不同高优先级天然就能抢占正在跑的低优先级服务函数不需要你在服务函数里手动开中断优先级相同的则不会互相打断。老式的 8259 中断控制器时代就不是这样中断服务函数入口会自动清掉中断允许位你如果想让更高优先级的中断进来必须在服务函数里手动执行一次sti而这又带来了重入风险。理解这两种模型的差别能帮你解释很多为什么我这段代码在 A 板子上没事、换到 B 板子就崩的怪现象。3.2 局部关中断与全局关中断多核环境下的分水岭在单核时代关中断就是字面意思——CPU 只有一颗关了它谁也进不来。到了多核事情变了cli这样的指令只能关掉当前这颗核的中断别的核照样该收中断收中断照样能跑、能改内存。也就是说如果你在核 A 上关中断来保护一个共享变量核 B 上正在跑的代码完全不理会你这套照样可以在你读-改-写的中间把变量改掉。这时候光关中断已经保护不住了必须上**自旋锁spinlock**之类的多核同步原语。正确的组合通常是锁 关本地中断先拿锁保证同一时刻只有一颗核在临界区里再关本地中断保证这颗核上不会被中断服务函数打扰出来时反过来做。为什么拿了锁还要关中断因为即使你抢到了锁这颗核上万一来了个中断服务函数也要访问同一份数据它要么自旋等锁如果它自己也去拿锁就可能死锁要么直接无视你把数据改坏。所以在这个数据结构会被中断服务函数访问的前提下锁和关中断缺一不可。这是从单核思路迁移到多核思路时最容易漏的一环。3.3 屏蔽寄存器与优先级比全开全关更细的粒度关中断/开中断是粗粒度的开关硬件其实还提供了更细的粒度——中断屏蔽寄存器和优先级屏蔽。前者可以针对某一号中断单独开关比如你只想屏蔽串口中断但保留定时器中断改对应的屏蔽位就行后者更灵活Cortex-M 的 BASEPRI 寄存器可以设定一个门槛凡是优先级数值高于即优先级低这个门槛的中断都被屏蔽而更紧急的中断仍能穿透。举个例子你在处理一段不能被普通中断打扰的代码但又希望最高优先级的故障中断随时能进来就可以把 BASEPRI 设成某个中间值实现只放行一部分。用细细粒度的屏蔽好处是延迟可控。总比一刀切全关要温和得多全关中断意味着所有中断的延迟都被你的临界区拖长而精确屏蔽只影响那些你确实不想被打扰的中断其余照常响应。我在做中断优化时的一条经验是能精确屏蔽就别全关能用硬件仲裁就别手写开关。硬件本来就为不同紧急程度设计了优先级你不用它反而用最粗暴的方式全关等于白白牺牲了系统的实时性。4. 三套平台的开关中断实操写法概念讲完了落到代码上。不同平台开关中断的写法差异很大这里挑三个最常见的——裸机 x86、Cortex-M 单片机、Linux 内核——分别看一眼重点是理解每种写法的保护范围和恢复语义而不是死记指令。下面这张表先把它们对照起来。平台关中断开中断保存/恢复关键注意点x86 裸机clistipushf/popfsti会在下一条指令后才生效Cortex-M__disable_irq()__enable_irq()读 PRIMASK直接调用会破坏外层状态Linux 内核local_irq_disable()local_irq_enable()local_irq_save(flags)优先用 save/restore 版本4.1 x86 的 cli / sti 与 EFLAGS.IFx86 上关中断靠的是cliClear Interrupt flag指令它把 EFLAGS 寄存器里的 IF 位清 0开中断靠stiSet Interrupt flag把 IF 置 1。这里有几个细节值得记住。第一sti不是马上生效的它要等下一条指令执行完才真正打开中断这个设计是为了保证sti; ret这种开完中断就返回的序列不会在返回之前被中断插一脚。第二EFLAGS 里除了 IF 还有一堆标志位恢复时不能自己造一个值塞回去要么用pushf/popf成对保存恢复要么就在关中断前把整个标志寄存器存下来。第三现代 x86 上sti和cli属于特权指令用户态跑不了所以你在应用程序里是没法直接关中断的这件事只能由内核来做。如果用内联汇编写常见的样子是这样// 保存标志寄存器并关中断 unsigned long flags; asm volatile(pushf; popq %0; cli : r(flags) :: memory); // ... 临界区 ... // 恢复标志寄存器中断状态随之恢复 asm volatile(pushq %0; popf :: r(flags) : memory, cc);注意这里用恢复而不是直接sti原因在下一小节的 Cortex-M 里会更明显——无条件开中断会把外层的保护也给解掉。4.2 Cortex-M 的 PRIMASK、BASEPRI 与 CMSIS 封装Cortex-M 把中断开关做成了内核寄存器。最常用的是PRIMASK它只有一位置 1 就屏蔽所有可配置优先级的中断NMI 和 HardFault 除外清零就放开。CMSIS 提供了两个函数__disable_irq()写 PRIMASK 为 1__enable_irq()写 PRIMASK 为 0。看着简单坑也正在这里__enable_irq()是无条件打开的。如果你在临界区 A 里关了中断A 里面又调用了某个函数 BB 也关中断、处理完调用__enable_irq()打开——B 一返回A 的保护就没了A 剩下的代码暴露在中断之下。正确做法是保存和恢复 PRIMASK而不是无脑开关uint32_t primask __get_PRIMASK(); // 记住关之前的原始状态 __disable_irq(); // 关 // ... 临界区 ... __set_PRIMASK(primask); // 恢复到进入前的样子而不是强行打开这套保存原始状态、恢复原始状态的模式是嵌套临界区能正确工作的关键。除了 PRIMASK还有FAULTMASK连 HardFault 都屏蔽只在极特殊场景用用错会让系统无法调试和前面提到的BASEPRI按优先级门槛屏蔽适合需要放行高优先级中断的场合。stm32 上大部分场景用 PRIMASK 的保存恢复就够了只有在实时性要求高、又确实需要放行某几类中断时才值得动用 BASEPRI。4.3 Linux 内核的 local_irq_save 与自旋锁的搭配Linux 内核把上面这套保存再恢复的思路封装成了现成的接口这也是它的代码里几乎看不到裸的关中断/开中断的原因。常用的四件套是local_irq_disable()/local_irq_enable()做简单的本地关闭与打开local_irq_save(flags)/local_irq_restore(flags)做带保存的关闭与恢复。内核规范里明确推荐后者因为你能保证退出时状态和进入时一致不会把外层已经关掉的中断给打开。真正在驱动里保护共享数据用的通常不是这四个函数而是带中断保护的自旋锁变体比如spin_lock_irqsave(lock, flags)和spin_unlock_irqrestore(lock, flags)。它的语义是拿锁的同时关本地中断并把原来状态存进 flags出来时放锁并恢复中断状态。这里面的逻辑值得琢磨为什么要先拿锁再关中断而不是先关中断再拿锁因为如果先关中断再等锁而锁正被另一颗核持有、那颗核又在等一个需要你这颗核处理的中断就可能卡死。内核里凡是在中断上下文和进程上下文都会访问的数据结构几乎都用这套接口你写驱动时照着抄基本不会错。5. 把关了多久量化出来中断延迟的组成与测量前面反复说临界区别太长,可长到底是多少、怎么量才是工程上真正管用的东西。这一节讲清楚一次中断从发生到真正进服务函数要经过哪些环节以及怎么用低成本的手段把它测出来。很多人调试中断问题全靠猜其实只要把延迟量出来问题往往一眼就露出来了。5.1 一次中断从发生到进入服务函数的完整时间轴一次硬件中断从事件发生到服务函数第一条指令执行中间要经过好几段。第一段是外设到中断控制器的传播外设置起中断标志信号要经过同步逻辑传到中断控制器这部分通常是几个时钟周期几乎可以忽略。第二段是中断控制器的仲裁控制器要判断这个中断的优先级、当前有没有更高优先级的中断在服务、能不能抢占多个中断同时来的时候还要排队这段在高负载系统里会明显变长。第三段是CPU 的响应与压栈CPU 完成当前指令、识别中断、把现场程序计数器、状态寄存器、部分通用寄存器压栈Cortex-M 上这一步是硬件自动完成的大约十几个周期但如果是带 FPU 的芯片并且中断里用了浮点压栈会多出几十个周期。第四段才是真正的服务函数入口到这里为止的时间就是硬件中断延迟。如果中间还有一段关中断那么这段延迟还要再加上距离关中断结束还剩多少时间。所以系统的最坏情况中断延迟 ≈ 硬件响应时间 最长临界区的关中断时间。想优化延迟要么减小硬件开销比如别在中断里用浮点、别开太深的栈要么压缩临界区长度后者通常是更可控的一头。5.2 用 GPIO 翻转加逻辑分析仪测关键路径测中断延迟不一定需要专业设备一根 GPIO 加一台逻辑分析仪甚至便宜的示波器就够。思路是在临界区进入时把一个空闲引脚拉高退出时拉低用逻辑分析仪测这段高电平的宽度就是你的关中断时长。测中断延迟则反过来让外设产生一个中断同时在中断服务函数第一条语句处翻转另一个引脚用两个通道看它们之间的时间差。前提是外设产生中断的瞬间你能用第三个通道或者已知的波形参照出来比如用一个定时器输出一个已知相位的方波中断打在这条方波的边沿上。在 Cortex-M 上还有个更精细的办法——用 DWT 里的周期计数器CYCCNT在服务函数入口读一次、出口读一次就能得到服务函数执行了多少个时钟周期再除以主频换算成时间。这个办法对定位到底哪个中断服务函数跑太久极其有效。我排查过一个案子主程序一切正常设备运行几分钟后偶尔卡死最后用这个方法量出来是某个串口中断服务函数里的printf占了几毫秒——串口在中断里打印本身就是在中断里做了极慢的阻塞操作一个字符还没发完下一个中断就来了层层叠加最终把系统拖垮。把打印挪到主循环里之后问题当场消失。5.3 DMA 加空闲中断把中断次数压到最低的思路说到中断优化有一个思路特别值得单独拎出来讲就是减少中断次数本身。中断不是越多越好每一次中断都有压栈、仲裁、跳转、返回的开销数据量大、速率高的时候逐字节中断会把 CPU 的时间全吃在进出中断上。串口接收就是典型场景波特率拉高之后每个字节都触发一次接收中断CPU 光是应付中断就快忙不过来解析逻辑根本没时间跑。一个成熟的做法是DMA 加空闲中断用 DMA 把串口收到的数据自动搬进内存缓冲区CPU 完全不参与逐字节搬运然后再开一个空闲中断总线检测到超过一帧时间没有新数据就触发只有当一整包数据收完、线路安静下来时才打断 CPU 一次让它一次性处理整帧。这样一来一包数据从 N 次中断降到了 1 次中断CPU 的负担瞬间下来丢字节的概率也大幅降低。这个模式在 stm32 上很常见也是中断优化里最有效的一招之一。它背后的思想可以推广到很多场景——能用 DMA 搬的活就别让 CPU 靠中断一个个来让硬件去干重复劳动CPU 只在整件事完成的关键节点被打扰一次。6. 关中断与开中断的常见错法六个真实踩过的坑前面讲原理和写法这一节专门讲错法。下面这些都是我和身边人在真实项目里遇到过的每一条都配了排查思路你可以对照自己的代码自查。6.1 忘记恢复以及恢复得太早第一类错误是忘记恢复进了临界区关了中断中途因为某个return、某个出错分支、甚至某个异常提前出去了开中断那句没被执行到中断就一直关着。单核系统里中断全关着意味着定时器不走了、串口不收了、看门狗喂不上了表现就是程序跑着跑着卡死。排查这类问题有个笨办法很好用在关中断和开中断两个点各翻转一个专用 GPIO用逻辑分析仪看这个引脚的高电平是不是出现了一直不落的情况落不下去的地方就是漏了恢复。写代码时则尽量用成对、单出口的写法别让临界区里有多个return。第二类相反是恢复得太早。你以为共享数据已经处理完了提前把中断打开结果后面还有几行也动到了这份数据保护就形同虚设。这种 bug 更隐蔽因为它不会死机只会偶尔丢数据正是前面说的那种偶发竞态。6.2 在临界区里干了不该干的活临界区里最不该干的事按危险程度排大概是这样调用可能会睡眠的操作Linux 里关中断上下文中睡眠会直接崩、做浮点运算在带 FPU 的 Cortex-M 上会触发额外的压栈开销、打印日志极慢占用中断禁用的时间以毫秒计、等硬件标志位可能永远等不到尤其在中断被关掉的情况下。这几样里打印和等标志位是最常见的。等标志位尤其阴险你在关中断的状态下等一个需要中断才能被置位的标志那它永远不会置位直接死循环。判断一段代码该不该放进临界区我习惯问自己一句这段代码里有没有任何一步会等别人只要有等待就不该出现在关中断的保护范围里因为它把关中断时间从一个确定的短值变成了不确定的长值。6.3 多核下我明明关了中断的幻觉这个坑在第 3.2 节提过这里再强调一次因为它太常见了。移植代码从单核到多核、或者从 MCU 到带多核的应用处理器时原来的关中断保护共享变量会突然失效。现象是偶发的数据错乱加日志更难复现因为多核的时序比单核复杂得多。排查时先确认一件事这份数据是不是不只被本地中断访问、还被别的核访问如果是光关中断远远不够必须上跨核的锁。记住单核的原子到了多核经常就不再原子了。6.4 中断服务函数里再开中断引起的重入最后一个坑和开中断直接相关。有的服务函数执行时间偏长开发者为了不让其它中断等太久在服务函数中途手动开了中断让高优先级中断能进来。想法没错但如果你没考虑重入就会出问题同一个服务函数可能被自己或同类中断再次进入上一轮的局部变量、被改了一半的全局状态还没收拾好新的执行流就冲进来了。表现为数据错乱甚至栈溢出。正确的做法是如果平台支持硬件优先级抢占比如 Cortex-M就让硬件去处理嵌套别手动开关如果确实要手动开务必保证服务函数是可重入的——不依赖会被打断的全局中间状态或者用重入计数保护起来。写中断服务函数有个总原则我一直坚持尽量短、尽量纯粹、只做最紧急的那一件事剩下的留给主循环。能遵守这条上面一大半的坑会自动绕开。我个人实际的体会是中断这块最难的不是写出能跑的代码而是写出在最坏时序下也不会错的代码。临界区长度、开关中断的配对、多核与中断的组合这些平时看不出问题一到高负载或者现场工况恶劣的时候才暴露。所以每写完一段带临界区的代码我都会刻意问自己三件事这段关中断最长会关多久、它有没有可能漏掉恢复、这份数据会不会被别的核碰到。把这三个问题答清楚代码才敢放心交出去。
返回列表