3、中断中的临界区保护:关中断与开中断、使用互斥锁、原子操作
临界区保护,说白了就是防止多个执行流同时访问共享资源。在中断服务程序里,这个问题尤其突出。你想想看,主循环正在修改一个全局变量,突然中断来了,ISR也去改同一个变量——那结果就乱套了。
我刚开始写嵌入式程序时,就吃过这个亏。一个简单的计数器,主循环里累加,定时器中断里读取,结果读出来的值总是莫名其妙。查了两天,才发现是临界区没保护好。嗯,从那以后,我对临界区保护就特别上心。
3.1 关中断与开中断:最原始也最有效
关中断是最直接的临界区保护方式。说白了,就是告诉CPU:「接下来这段代码,谁也别来打断我」。等关键操作做完了,再把中断打开。
在ARM Cortex-M系列上,常用的方法是操作PRIMASK寄存器:
// 关中断
uint32_t __disable_irq(void)
{
uint32_t mask;
__asm volatile("MRS %0, PRIMASK" : "=r" (mask));
__asm volatile("CPSID I");
return mask;
}
// 开中断
void __enable_irq(uint32_t mask)
{
__asm volatile("MSR PRIMASK, %0" : : "r" (mask));
}
注意,我习惯用「保存-恢复」的方式,而不是简单的关-开。为什么?因为中断可能嵌套。如果函数A关了中断,函数B又关了一次,然后B先开了中断——那A的临界区就暴露了。
正确做法:
void critical_section_enter(void)
{
// 保存当前中断状态,然后关中断
saved_mask = __disable_irq();
}
void critical_section_exit(void)
{
// 恢复到进入前的状态,不是简单开中断
__enable_irq(saved_mask);
}
⚠️ 重要提醒:关中断的时间一定要短。我曾经见过一个项目,关中断时间超过100微秒,结果UART接收溢出、定时器精度下降。记住:关中断是「快进快出」,别在里面做复杂运算。
3.2 使用互斥锁:更灵活的保护方式
关中断虽然简单,但太粗暴了。你想想看,如果只是保护一个共享变量,却把整个中断系统都关了,是不是有点「杀鸡用牛刀」?
互斥锁(Mutex)就是更精细的方案。它只保护特定的资源,不影响其他中断的响应。
在RTOS环境下,互斥锁的使用很普遍:
// 定义互斥锁
OS_MUTEX my_mutex;
// 在ISR中获取锁
void TIM2_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 尝试获取互斥锁
if (xSemaphoreTakeFromISR(my_mutex, &xHigherPriorityTaskWoken) == pdTRUE)
{
// 访问共享资源
shared_data++;
// 释放互斥锁
xSemaphoreGiveFromISR(my_mutex, &xHigherPriorityTaskWoken);
}
// 如果有更高优先级的任务被唤醒,请求上下文切换
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
这里有个坑,我踩过。在ISR里获取互斥锁,必须使用带「FromISR」后缀的API。普通任务里的API会阻塞,而ISR里不能阻塞——这是基本常识,但新手容易忽略。
💡 我的经验:在裸机环境下,可以用「自旋锁」的思想实现轻量级互斥。但要注意,自旋锁在单核CPU上其实不需要「自旋」,用关中断就够了。多核才需要真正的自旋。
3.3 原子操作:硬件级别的保护
原子操作,是硬件直接支持的不可分割操作。说白了,就是CPU保证这个操作要么做完,要么没做,不会出现「做一半被打断」的情况。
在ARM Cortex-M3/M4上,常用的原子操作有:
- LDREX/STREX:独占加载和存储,用于实现锁
- 原子加减:直接对内存变量进行加减
- 位带操作:原子地置位或清零某个位
看个例子,用原子操作实现共享变量自增:
// 原子自增(Cortex-M专用)
static inline uint32_t atomic_inc(uint32_t *addr)
{
uint32_t result;
uint32_t new_val;
__asm volatile(
"1: LDREX %0, [%2]\n" // 独占加载
" ADD %1, %0, #1\n" // 加1
" STREX %0, %1, [%2]\n" // 独占存储
" CMP %0, #0\n" // 检查是否成功
" BNE 1b\n" // 不成功则重试
: "=&r" (result), "=&r" (new_val)
: "r" (addr)
: "memory"
);
return new_val;
}
// 使用
volatile uint32_t counter = 0;
void TIM2_IRQHandler(void)
{
// 原子自增,不需要关中断
atomic_inc(&counter);
}
为什么原子操作不需要关中断?因为LDREX/STREX是硬件保证的。即使中断在LDREX和STREX之间发生,STREX也会失败,然后重试。整个过程对软件是透明的。
三种方式的对比:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 关中断 | 简单、通用 | 影响中断响应 | 临界区很短 |
| 互斥锁 | 精细控制 | 需要RTOS支持 | 多任务环境 |
| 原子操作 | 零开销、不影响中断 | 仅支持简单操作 | 计数器、标志位 |
⚠️ 避坑指南:我曾经在一个项目里,用原子操作保护一个结构体。结果发现,原子操作只能保护单个变量,结构体赋值不是原子的。最后数据还是乱了。记住:原子操作只适用于「单个内存访问」,复合操作必须用关中断或互斥锁。
3.4 实际项目中的选择策略
说了这么多,到底该用哪种?我个人的经验是:
- 保护单个变量(计数器、标志位):用原子操作,零开销
- 保护短代码段(赋值、判断):用关中断,但要控制时间
- 保护复杂操作(链表操作、缓冲区管理):用互斥锁
- ISR和任务共享数据:优先用互斥锁,避免长时间关中断
嗯,这里要注意:不要为了「省事」就一律关中断。我见过一个项目,所有临界区都用关中断,结果系统响应延迟从10微秒飙升到500微秒。后来改成互斥锁和原子操作,问题就解决了。
最后说一句:临界区保护没有银弹。你得根据实际情况,选择最合适的方式。多想想「如果这里被打断了会怎样」,就能做出正确的选择。