3. 中断与并发陷阱:中断服务程序过长、中断优先级反转、可重入性问题、临界区保护不当
中断,是嵌入式系统里最锋利的刀。用好了,你能在微秒级响应外部事件;用不好,它能把整个协议栈捅得千疮百孔。我见过太多协议栈适配翻车,十有八九都跟中断处理有关。
这一章,咱们就聊聊中断和并发场景下最常见的四个坑:ISR 写得像裹脚布、优先级反转、函数不可重入、临界区保护形同虚设。每个坑我都踩过,有些甚至是在客户现场才暴露的——那种感觉,你懂的。
3.1 中断服务程序过长:ISR 不是你家客厅
很多新手写 ISR,习惯把整个业务逻辑都塞进去。收到一个网络包,在 ISR 里解析、校验、查找路由表、转发……一气呵成。看起来效率很高,对吧?
其实这是个大坑。ISR 执行期间,同级和低优先级中断都被屏蔽了。你想想看,你在里面待 500 微秒,系统就 500 微秒没法响应其他中断。要是你待了 1 毫秒,那基本等于把实时性扔进了垃圾桶。
我个人的习惯是:ISR 只做三件事。
- 保存上下文(硬件已经做了大部分)
- 读取并清除中断标志
- 给任务或下半部发一个信号(比如设置一个标志位、释放一个信号量、往队列里扔一个事件)
剩下的处理,交给任务上下文去干。这就是经典的“上半部/下半部”模型。
衡量 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 才能响应。
高优先级中断被低优先级中断阻塞,这就是中断层面的优先级反转。
怎么解决?我个人建议三条路:
- 尽量不用关中断来保护临界区。能用信号量、互斥锁的,就别关中断。关中断是最后的手段。
- 如果必须关中断,时间要短。我给自己定的规矩是:关中断不超过 10 条指令,或者不超过 1 微秒。
- 使用中断嵌套。如果硬件支持,开启中断嵌套。高优先级中断可以打断低优先级 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 的中断,不会影响其他核(如果是多核的话)。
下面这张图,总结了中断与并发场景下的四个陷阱和对应的对策。
3.5 实战建议:中断处理的“黄金三原则”
说了这么多,最后总结三条我在项目中一直遵守的原则:
- ISR 里不要等。不要轮询、不要忙等、不要调用可能阻塞的函数(比如
vTaskDelay())。ISR 是“登记处”,不是“处理中心”。 - 关中断要像借钱一样——能少则少,能短则短。每次关中断,都是在透支系统的实时性。借了要记得还(开中断),不然系统会“破产”。
- 所有在 ISR 里调用的函数,默认都是不可重入的。除非你仔细检查过,并且做了保护。我吃过这个亏,所以现在写 ISR 时,会特意在函数名前加
isr_前缀,提醒自己这是中断上下文。
memcpy()。看起来没问题,对吧?但那个 memcpy() 的实现用了循环展开,里面有个静态变量做计数器。两个 ISR 一嵌套,计数器就乱了,数据拷贝直接出错。从那以后,我所有在 ISR 里用的函数,都会去翻它的源码,确认没有全局/静态变量。
中断和并发,是协议栈适配里最容易出问题的地方,也是最难调试的地方。因为问题往往是偶发的,跟时序强相关。你按照上面的方法去做,至少能避免 80% 的坑。剩下的 20%,嗯,那就得靠逻辑分析仪和耐心了。