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 实际项目中的选择策略

说了这么多,到底该用哪种?我个人的经验是:

  1. 保护单个变量(计数器、标志位):用原子操作,零开销
  2. 保护短代码段(赋值、判断):用关中断,但要控制时间
  3. 保护复杂操作(链表操作、缓冲区管理):用互斥锁
  4. ISR和任务共享数据:优先用互斥锁,避免长时间关中断

嗯,这里要注意:不要为了「省事」就一律关中断。我见过一个项目,所有临界区都用关中断,结果系统响应延迟从10微秒飙升到500微秒。后来改成互斥锁和原子操作,问题就解决了。

最后说一句:临界区保护没有银弹。你得根据实际情况,选择最合适的方式。多想想「如果这里被打断了会怎样」,就能做出正确的选择。