3. 中断与并发陷阱:中断服务程序过长、中断优先级反转、可重入性问题、临界区保护不当

中断,是嵌入式系统里最锋利的刀。用好了,你能在微秒级响应外部事件;用不好,它能把整个协议栈捅得千疮百孔。我见过太多协议栈适配翻车,十有八九都跟中断处理有关。

这一章,咱们就聊聊中断和并发场景下最常见的四个坑:ISR 写得像裹脚布、优先级反转、函数不可重入、临界区保护形同虚设。每个坑我都踩过,有些甚至是在客户现场才暴露的——那种感觉,你懂的。

核心观点:中断处理不是“把代码放进 ISR 就行”。你需要把 ISR 当作一个“紧急事件登记处”,而不是“事件处理中心”。

3.1 中断服务程序过长:ISR 不是你家客厅

很多新手写 ISR,习惯把整个业务逻辑都塞进去。收到一个网络包,在 ISR 里解析、校验、查找路由表、转发……一气呵成。看起来效率很高,对吧?

其实这是个大坑。ISR 执行期间,同级和低优先级中断都被屏蔽了。你想想看,你在里面待 500 微秒,系统就 500 微秒没法响应其他中断。要是你待了 1 毫秒,那基本等于把实时性扔进了垃圾桶。

我个人的习惯是:ISR 只做三件事。

  • 保存上下文(硬件已经做了大部分)
  • 读取并清除中断标志
  • 给任务或下半部发一个信号(比如设置一个标志位、释放一个信号量、往队列里扔一个事件)

剩下的处理,交给任务上下文去干。这就是经典的“上半部/下半部”模型。

经验之谈:我在一个 4G 模块的协议栈适配中,遇到过 ISR 里做 CRC 校验的情况。CRC 计算本身不复杂,但在 115200bps 的串口上,每字节都触发中断,ISR 里再做 CRC,直接导致丢包率飙升。后来把 CRC 挪到任务里做,问题立刻解决。

衡量 ISR 是否过长,有个简单的标准:ISR 执行时间 < 中断触发间隔的 1/3。如果做不到,就得考虑拆分。

场景 ISR 内处理 ISR 外处理
接收一个字节 读 FIFO,存缓冲区 解析协议帧
定时器超时 设置标志位 执行超时回调
GPIO 边沿触发 记录时间戳,触发任务 处理事件逻辑

3.2 中断优先级反转:高优先级中断被“饿死”

优先级反转这个词,做 RTOS 的人都不陌生。但在中断层面,它同样存在,而且更隐蔽。

举个例子。你的系统里有两个中断:IRQ_A(高优先级)和 IRQ_B(低优先级)。IRQ_B 的 ISR 里访问了一个共享资源,并且用关中断来保护临界区。这时候 IRQ_A 触发了——但 IRQ_B 已经把中断总开关关了,IRQ_A 只能干等着。等 IRQ_B 处理完,IRQ_A 才能响应。

高优先级中断被低优先级中断阻塞,这就是中断层面的优先级反转。

注意:关中断保护临界区,本质上是把当前 CPU 的优先级提升到最高。如果你在低优先级 ISR 里长时间关中断,所有更高优先级的中断都会被延迟。这不是“优先级反转”是什么?

怎么解决?我个人建议三条路:

  1. 尽量不用关中断来保护临界区。能用信号量、互斥锁的,就别关中断。关中断是最后的手段。
  2. 如果必须关中断,时间要短。我给自己定的规矩是:关中断不超过 10 条指令,或者不超过 1 微秒。
  3. 使用中断嵌套。如果硬件支持,开启中断嵌套。高优先级中断可以打断低优先级 ISR。但这要求你的 ISR 必须是可重入的——这就引出了下一个话题。

3.3 可重入性问题:同一个函数,两个中断同时在跑

可重入性,说白了就是一个函数在被调用执行的过程中,能不能被再次调用。如果能,它就是可重入的;如果不能,它就是不可重入的。

中断场景下,可重入性问题特别容易冒出来。比如你有一个函数 buffer_write(),它在 ISR 里被调用。如果这个函数里用了全局变量或者静态变量,而且没有保护,那么当 ISR 嵌套发生时,第二次调用就会把第一次调用的数据搞乱。

我曾经在调试一个 ZigBee 协议栈时遇到过这个问题。协调器偶尔会丢包,查了三天没头绪。最后用逻辑分析仪抓中断时序,发现是串口接收 ISR 和定时器 ISR 都调用了同一个缓冲区管理函数。那个函数里有个静态变量用来记录当前写入位置,两个 ISR 一嵌套,位置就乱了。

修复方法很简单:把静态变量改成参数传递,或者给每个调用者分配独立的上下文。

判断函数是否可重入的快速检查清单:
  • 是否使用了全局变量或静态变量?
  • 是否调用了不可重入的函数(比如 malloc、printf)?
  • 是否修改了硬件寄存器而没有保存/恢复?
  • 是否依赖固定的内存地址?

以上任意一条为“是”,这个函数就不可重入。在中断里调用它,就要小心了。

3.4 临界区保护不当:关中断不是万能药

临界区保护,是并发编程的基本功。但在中断场景下,很多人保护得不对。

最常见的错误是:在任务上下文里关中断来保护临界区。任务里关中断,意味着整个系统都停摆了——所有中断都被屏蔽,包括时钟中断。这会导致系统 tick 丢失,定时器不准,其他任务无法调度。

正确的做法是:区分临界区的类型。

  • 任务 vs 任务:用信号量或互斥锁保护。
  • 任务 vs ISR:用关中断保护,但只关当前 ISR 级别及以下的中断。
  • ISR vs ISR:用关中断保护,或者用硬件提供的原子操作。

很多 RTOS 提供了专门的 API,比如 taskENTER_CRITICAL() 和 taskEXIT_CRITICAL()。它们只关当前 CPU 的中断,不会影响其他核(如果是多核的话)。

一个小技巧:我习惯在临界区前后加断言,检查中断状态是否被正确恢复。比如进入前记录中断掩码,退出后检查掩码是否一致。这样能快速发现“忘记开中断”的 bug。

下面这张图,总结了中断与并发场景下的四个陷阱和对应的对策。

中断与并发陷阱及对策 ISR 过长 占用 CPU 时间过长 优先级反转 高优被低优阻塞 不可重入 函数被多次调用 临界区 保护不当 上半部/下半部 ISR 只做最小工作 任务处理业务逻辑 避免关中断 使用信号量/互斥锁 开启中断嵌套 避免全局/静态变量 使用参数传递 独立上下文 区分场景 任务 vs 任务 任务 vs ISR 核心原则:ISR 要短、快、可重入 临界区保护要精准,避免全局关中断

3.5 实战建议:中断处理的“黄金三原则”

说了这么多,最后总结三条我在项目中一直遵守的原则:

  1. ISR 里不要等。不要轮询、不要忙等、不要调用可能阻塞的函数(比如 vTaskDelay())。ISR 是“登记处”,不是“处理中心”。
  2. 关中断要像借钱一样——能少则少,能短则短。每次关中断,都是在透支系统的实时性。借了要记得还(开中断),不然系统会“破产”。
  3. 所有在 ISR 里调用的函数,默认都是不可重入的。除非你仔细检查过,并且做了保护。我吃过这个亏,所以现在写 ISR 时,会特意在函数名前加 isr_ 前缀,提醒自己这是中断上下文。
避坑指南:我曾经在一个项目里,为了“优化性能”,在 ISR 里直接调用了 memcpy()。看起来没问题,对吧?但那个 memcpy() 的实现用了循环展开,里面有个静态变量做计数器。两个 ISR 一嵌套,计数器就乱了,数据拷贝直接出错。从那以后,我所有在 ISR 里用的函数,都会去翻它的源码,确认没有全局/静态变量。

中断和并发,是协议栈适配里最容易出问题的地方,也是最难调试的地方。因为问题往往是偶发的,跟时序强相关。你按照上面的方法去做,至少能避免 80% 的坑。剩下的 20%,嗯,那就得靠逻辑分析仪和耐心了。