4. RTOS环境下的中断处理:中断与任务通信
中断服务程序(ISR)跑得再快,最终还是要跟任务层打交道。你想想看,按键按下后,ISR只是清除了抖动、记录了时间戳,但真正要处理这个按键事件的逻辑——比如切换界面、发送数据——总不能全塞在ISR里吧?
RTOS给我们提供了三种经典的通信手段:信号量、消息队列、事件标志组。我个人习惯把这三样东西比作“中断与任务之间的桥梁”。选对了桥,数据流就顺畅;选错了,要么丢数据,要么把ISR拖死。
4.1 为什么不能在ISR里做太多事?
先说个原则:ISR要短、平、快。我在项目中遇到过一位同事,把整个LCD刷新逻辑写进了GPIO中断里。结果呢?中断嵌套一多,系统直接死机。后来我帮他改成“ISR只发信号,任务慢慢处理”,问题就解决了。
RTOS环境下,ISR里不能调用阻塞式API。比如等待信号量、等待队列消息——这些都会让ISR卡住。你想想看,中断上下文里没有任务调度,一卡住整个系统就挂了。
xSemaphoreGiveFromISR()、xQueueSendFromISR()。用错了,系统直接崩溃。
4.2 信号量:最简单的“通知”机制
信号量就像一面旗子。ISR举旗,任务看到旗子就干活。最常用的是二值信号量(Binary Semaphore)。
我曾经在一个电机控制项目里用过这个模式:霍尔传感器触发中断,ISR释放信号量,控制任务获取信号量后计算转速。代码结构非常清晰。
// ISR中的写法
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务中的写法
void vControlTask(void *pvParameters)
{
while(1)
{
if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE)
{
// 处理中断事件
vCalculateSpeed();
}
}
}
xHigherPriorityTaskWoken 这个变量。如果ISR释放信号量后,等待该信号量的任务优先级比当前被中断的任务高,就需要立即切换。不写这个,实时性会打折扣。
4.3 消息队列:传递数据的最佳选择
信号量只能发“通知”,不能带数据。如果你需要把中断里采集到的数据传给任务,就得用消息队列。
举个例子:ADC中断采集到温度值,ISR把温度值塞进队列,任务从队列里取出来做滤波和显示。这样ISR只负责“放”,任务只负责“取”,互不干扰。
// ISR中发送数据
void ADC_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
uint32_t adc_value = ADC1->DR;
xQueueSendFromISR(xAdcQueue, &adc_value, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务中接收数据
void vAdcProcessTask(void *pvParameters)
{
uint32_t received_value;
while(1)
{
if(xQueueReceive(xAdcQueue, &received_value, portMAX_DELAY) == pdTRUE)
{
vFilterAndDisplay(received_value);
}
}
}
这里有个坑:队列长度要设置合理。我见过有人把队列长度设成1,结果中断来得快,队列满了就丢数据。建议根据中断频率和任务处理速度,留出足够的缓冲。
| 通信方式 | 适用场景 | 数据量 | 典型延迟 |
|---|---|---|---|
| 二值信号量 | 简单事件通知 | 无数据 | 最低 |
| 计数信号量 | 资源计数、事件计数 | 计数值 | 低 |
| 消息队列 | 数据传递 | 多字节 | 中等 |
| 事件标志组 | 多条件同步 | 位标志 | 低 |
4.4 事件标志组:处理“多条件”中断
有时候一个中断需要触发多个任务,或者一个任务需要等待多个中断条件都满足。这时候事件标志组就派上用场了。
我记得在一个多传感器数据采集项目里,系统需要等温度、湿度、气压三个传感器都采集完毕,才启动数据融合任务。用事件标志组,每个传感器中断设置自己的标志位,融合任务等待所有标志位都置1再执行。
// ISR中设置事件位
void TEMP_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xEventGroupSetBitsFromISR(xEventGroup, TEMP_BIT, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务中等待多个事件
void vFusionTask(void *pvParameters)
{
EventBits_t uxBits;
while(1)
{
uxBits = xEventGroupWaitBits(
xEventGroup,
TEMP_BIT | HUMI_BIT | PRESS_BIT,
pdTRUE, // 退出时清除标志位
pdTRUE, // 等待所有位都置位
portMAX_DELAY
);
vDataFusion();
}
}
4.5 中断与任务通信的完整流程
说了这么多,咱们画张图把整个流程串起来。从GPIO引脚电平变化,到ISR处理,再到任务响应,每一步都有讲究。
4.6 避坑指南:我踩过的几个雷
做了这么多年嵌入式,中断通信这块的坑我基本都踩过。分享几个典型的:
- 信号量“饿死”问题:我曾经在一个项目中,高优先级任务频繁获取信号量,低优先级任务一直拿不到。后来加了优先级继承或使用队列才解决。
- 队列溢出丢数据:ADC中断频率200kHz,队列长度只设了10。结果数据大量丢失。后来根据“最大中断间隔 × 任务处理时间”算出了合理队列深度。
- 事件标志位没清零:有一次调试,任务总是莫名其妙被唤醒。查了半天发现事件标志组没设置“退出时自动清除”。这个细节很容易忽略。
portYIELD_FROM_ISR() 时,一定要检查 xHigherPriorityTaskWoken 是否为 pdTRUE。如果不做这个判断,每次ISR都强制任务切换,系统性能会大幅下降。
4.7 性能调优:响应时间与吞吐量的平衡
最后聊点实战调优。中断响应时间和系统吞吐量往往是一对矛盾。我个人习惯这样权衡:
- 追求低延迟:用信号量 + 高优先级任务。ISR释放信号量后立即切换,任务几乎零延迟响应。代价是其他任务可能被饿死。
- 追求高吞吐:用消息队列 + 中等优先级任务。ISR只管往队列里塞数据,任务按自己的节奏处理。适合数据采集类应用。
- 追求灵活性:用事件标志组 + 多任务协同。适合需要多个条件触发的复杂场景。
嗯,这里要注意:没有万能的方案。我一般会先画一张时序图,把中断频率、任务处理时间、允许的最大延迟标出来,再选通信方式。纸上谈兵总比焊板子改代码来得快。