4、中断与任务通信:共享变量的保护、使用队列、使用信号量

好,咱们接着聊中断。前面几章我们把中断的“规矩”讲得差不多了,这一章要聊一个更实际、也更头疼的问题——中断怎么跟任务“说话”

你想想看,中断服务程序跑在“特权模式”下,任务跑在“普通模式”下。它们俩共享一块内存,这事儿本身就挺危险的。我早年做项目时,就吃过这个亏。一个全局变量在中断里被修改,主循环里读到一半,中断又来了……嗯,那画面太美我不敢看。

所以,这一章的核心就三个字:怎么保护

共享变量的保护:别让数据“打架”

先说最简单的场景。中断里改一个标志位,主循环里读这个标志位。比如这样:

// 错误示范
volatile uint8_t g_flag = 0;

void ISR_Handler(void) {
    g_flag = 1;  // 中断里写
}

void main_loop(void) {
    if (g_flag) {  // 主循环里读
        // 处理事件
        g_flag = 0;
    }
}

这段代码看着没问题吧?其实问题大了。如果中断在 if (g_flag) 判断之后、g_flag = 0 之前触发,那这个事件就丢了。更糟的是,如果变量是 16 位或 32 位的,读写不是原子的,你可能会读到“半新半旧”的值。

⚠️ 注意: 共享变量必须用 volatile 修饰,防止编译器优化。但 volatile 不解决原子性问题!

那怎么办?我个人习惯的做法是:能关中断就关中断。在访问共享变量前,先关掉中断,操作完再打开。像这样:

// 推荐做法:关中断保护
void main_loop(void) {
    uint8_t flag_copy;
    
    __disable_irq();           // 关中断
    flag_copy = g_flag;        // 原子读取
    g_flag = 0;                // 原子清零
    __enable_irq();            // 开中断
    
    if (flag_copy) {
        // 处理事件
    }
}

我曾经在一个电机控制项目里,就因为没做这个保护,导致速度采样值偶尔跳变。查了两天才找到原因——中断里更新了采样值,主循环里读到一半,中断又来了。嗯,从那以后我再也不敢偷懒了。

使用队列:中断“生产”,任务“消费”

共享变量保护虽然简单,但只适合传递“标志”这种小数据。如果你要在中断里传递一批数据,比如串口接收到的字节流,那共享变量就不够用了。这时候,队列就派上用场了。

队列的本质是一个环形缓冲区。中断往里写,任务从里读。只要队列是线程安全的,就不会出问题。

我常用的队列实现长这样:

// 简单环形队列
#define QUEUE_SIZE 64

typedef struct {
    uint8_t buffer[QUEUE_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_queue_t;

// 中断里写
bool queue_push(ring_queue_t *q, uint8_t data) {
    uint16_t next = (q->head + 1) % QUEUE_SIZE;
    if (next == q->tail) {
        return false;  // 队列满
    }
    q->buffer[q->head] = data;
    q->head = next;
    return true;
}

// 任务里读
bool queue_pop(ring_queue_t *q, uint8_t *data) {
    if (q->head == q->tail) {
        return false;  // 队列空
    }
    *data = q->buffer[q->tail];
    q->tail = (q->tail + 1) % QUEUE_SIZE;
    return true;
}
💡 小技巧: 队列的 headtail 一定要用 volatile 修饰。因为中断和任务会同时修改它们,编译器可能会把它们优化到寄存器里,导致数据不同步。

你可能会问:队列满了怎么办?我个人建议,宁可丢数据,也别让中断卡住。中断服务程序讲究“快进快出”,如果因为队列满而等待,那整个系统的实时性就毁了。所以,队列满时直接丢弃新数据,或者覆盖旧数据,都行。关键是要有日志或计数器,方便调试。

使用信号量:让任务“等一等”

队列解决了数据传递的问题,但还有一个问题:任务怎么知道队列里有数据了? 总不能一直轮询吧?那太浪费 CPU 了。

这时候,信号量就登场了。信号量说白了就是一个计数器。中断里“给”一个信号量,任务里“等”这个信号量。任务在等的时候可以休眠,不占 CPU。

在裸机环境下,信号量通常配合关中断实现。比如:

// 简单信号量
typedef struct {
    volatile uint32_t count;
} sem_t;

// 中断里“给”信号量
void sem_give_from_isr(sem_t *s) {
    s->count++;
    // 如果有任务在等,可以在这里设置一个“需要调度”的标志
}

// 任务里“等”信号量
void sem_take(sem_t *s) {
    __disable_irq();
    while (s->count == 0) {
        __enable_irq();
        // 这里可以休眠,或者让出 CPU
        __disable_irq();
    }
    s->count--;
    __enable_irq();
}

核心要点: 信号量的“给”操作必须在中断里完成,“等”操作必须在任务里完成。千万别搞反了!我曾经见过有人把 sem_take 放在中断里,结果系统直接死锁——中断在等信号量,而信号量只能由中断自己给……嗯,这属于“自己把自己锁死”的经典案例。

如果你用的是 RTOS,那更简单了。RTOS 通常提供了现成的队列和信号量 API,而且内部已经做好了中断保护。比如 FreeRTOS 的 xQueueSendFromISRxSemaphoreGiveFromISR,直接用就行。

三种方式的对比

说了这么多,咱们来总结一下。共享变量、队列、信号量,各有各的适用场景:

方式 适用场景 优点 缺点
共享变量 传递标志、状态 简单、开销小 只能传少量数据,需要关中断保护
队列 传递数据流、消息 支持多数据、解耦生产者和消费者 需要管理缓冲区大小
信号量 同步、通知 任务可以休眠,不占 CPU 只能传递“有/无”信息

我个人习惯是:能用队列就别用共享变量,能用信号量就别用轮询。当然,具体用哪种,还得看你的系统复杂度和实时性要求。小项目用共享变量就够了,大项目还是老老实实上队列和信号量吧。

避坑指南

最后,分享几个我踩过的坑:

  • 别在中断里做复杂操作。 队列的入队操作要尽量快,最好只做指针移动和赋值。千万别在中断里做内存分配、打印日志这种事。
  • 注意优先级反转。 如果低优先级中断和高优先级中断共享同一个队列,高优先级中断可能会被低优先级中断阻塞。解决办法是:给每个中断分配独立的队列。
  • 测试要覆盖边界情况。 我曾经遇到过队列满时,中断里写入失败,但任务还在傻等数据。后来加了一个“队列溢出计数器”,才把问题揪出来。

嗯,这一章的内容就到这里。下一章咱们聊聊中断的嵌套和优先级设计,那又是一个容易翻车的地方。