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;
}
head 和 tail 一定要用 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 的 xQueueSendFromISR 和 xSemaphoreGiveFromISR,直接用就行。
三种方式的对比
说了这么多,咱们来总结一下。共享变量、队列、信号量,各有各的适用场景:
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 共享变量 | 传递标志、状态 | 简单、开销小 | 只能传少量数据,需要关中断保护 |
| 队列 | 传递数据流、消息 | 支持多数据、解耦生产者和消费者 | 需要管理缓冲区大小 |
| 信号量 | 同步、通知 | 任务可以休眠,不占 CPU | 只能传递“有/无”信息 |
我个人习惯是:能用队列就别用共享变量,能用信号量就别用轮询。当然,具体用哪种,还得看你的系统复杂度和实时性要求。小项目用共享变量就够了,大项目还是老老实实上队列和信号量吧。
避坑指南
最后,分享几个我踩过的坑:
- 别在中断里做复杂操作。 队列的入队操作要尽量快,最好只做指针移动和赋值。千万别在中断里做内存分配、打印日志这种事。
- 注意优先级反转。 如果低优先级中断和高优先级中断共享同一个队列,高优先级中断可能会被低优先级中断阻塞。解决办法是:给每个中断分配独立的队列。
- 测试要覆盖边界情况。 我曾经遇到过队列满时,中断里写入失败,但任务还在傻等数据。后来加了一个“队列溢出计数器”,才把问题揪出来。
嗯,这一章的内容就到这里。下一章咱们聊聊中断的嵌套和优先级设计,那又是一个容易翻车的地方。