4. 中断与任务同步:信号量与消息队列在中断中的应用

中断服务程序(ISR)里能不能调用 RTOS 的 API?能,但得小心。我见过不少新手直接把 printf 和延时塞进中断,结果系统直接挂掉。中断里最核心的原则就一条:快进快出。那数据怎么交给任务?信号量和消息队列就是干这个的。

4.1 为什么中断里不能直接处理数据?

中断是最高优先级的执行上下文。它抢占了任务的运行时间。如果你在 ISR 里做复杂运算、调用阻塞函数,后果很严重:

  • 中断延迟飙升 — 其他中断进不来,系统响应变慢
  • 任务饿死 — 任务永远抢不到 CPU
  • 栈溢出 — 中断栈通常很小,经不起折腾

所以,中断的正确姿势是:只做最必要的硬件操作,然后把数据“扔”给任务去处理。扔的方式有两种:信号量和消息队列。

4.2 信号量:中断通知任务的“门铃”

信号量本质上是一个计数器。中断里释放信号量,任务里获取信号量。这就像按门铃:中断按一下,任务开门处理。

4.2.1 二值信号量 vs 计数信号量

类型 适用场景 中断释放行为
二值信号量 一次中断触发一次处理 计数值从 0 → 1,任务取走变回 0
计数信号量 中断频繁触发,任务批量处理 每次释放 +1,任务取走 -1

我个人习惯在 UART 接收中断里用计数信号量。为什么?因为串口数据可能一包接一包地来,二值信号量会丢事件。计数信号量能记录“来了多少次”,任务可以一次取完。

4.2.2 典型代码:UART 接收中断 + 信号量

/* 中断服务程序 */
void UART_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    uint8_t byte;

    /* 读取硬件数据 */
    byte = UART->DR;

    /* 存入环形缓冲区(中断里必须做) */
    ringbuf_write(&rx_ring, byte);

    /* 释放信号量,通知任务 */
    xSemaphoreGiveFromISR(xRxSemaphore, &xHigherPriorityTaskWoken);

    /* 如果任务优先级更高,立即切换 */
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

/* 任务:处理接收数据 */
void vUARTTask(void *pvParameters)
{
    uint8_t data;
    while (1)
    {
        /* 等待信号量,超时设为 portMAX_DELAY */
        if (xSemaphoreTake(xRxSemaphore, portMAX_DELAY) == pdTRUE)
        {
            /* 从环形缓冲区读取数据并处理 */
            while (ringbuf_read(&rx_ring, &data))
            {
                process_byte(data);
            }
        }
    }
}
注意:中断里必须使用 FromISR 后缀的 API。普通版本的 API 会尝试获取调度器锁,中断里不能这么做。我曾经见过有人用 xSemaphoreGive 在中断里,结果系统直接 hard fault。

4.3 消息队列:中断向任务传递“数据包”

信号量只传递“事件发生了”,不传递具体数据。如果你需要把收到的字节、状态码、甚至结构体传给任务,就得用消息队列。

4.3.1 队列在中断中的使用原则

  • 队列长度要足够 — 中断可能连续发多条消息,队列满了会丢数据
  • 数据拷贝要轻量 — 中断里传指针比传大结构体快得多
  • 使用 xQueueSendFromISR — 不要用普通版本

4.3.2 典型代码:UART 接收中断 + 消息队列

/* 定义队列句柄 */
QueueHandle_t xUARTQueue;

/* 中断服务程序 */
void UART_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    uint8_t byte;

    byte = UART->DR;

    /* 把字节发送到队列 */
    xQueueSendFromISR(xUARTQueue, &byte, &xHigherPriorityTaskWoken);

    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

/* 任务:从队列接收数据 */
void vUARTTask(void *pvParameters)
{
    uint8_t rx_byte;
    while (1)
    {
        if (xQueueReceive(xUARTQueue, &rx_byte, portMAX_DELAY) == pdTRUE)
        {
            /* 处理收到的字节 */
            handle_rx_byte(rx_byte);
        }
    }
}
我的经验:如果数据量很大(比如 1 Mbps 的串口),队列拷贝的开销会很明显。这时候我通常用“队列传指针 + 内存池”的方案:中断里从内存池拿一块内存,填好数据,把指针塞进队列。任务处理完再释放内存。这样中断里只做指针赋值,速度极快。

4.4 信号量 vs 消息队列:怎么选?

对比维度 信号量 消息队列
传递数据 否(只传事件) 是(传数据副本或指针)
中断开销 极低(计数器 +1) 中等(内存拷贝)
适用场景 通知任务“有数据了” 通知任务“这是数据”
丢事件风险 计数信号量可累积 队列满则丢

说白了,如果你只需要告诉任务“去干活”,用信号量。如果你需要告诉任务“干这个活,数据在这”,用消息队列。我自己的项目里,经常两者结合:信号量做“门铃”,队列做“快递员”。

4.5 避坑指南:中断同步的常见陷阱

陷阱 1:在中断里调用阻塞 API

中断里不能等信号量、不能等队列。所有 Take、Receive 操作都必须在任务里做。中断只能 Give 和 Send。

陷阱 2:忘记检查 xHigherPriorityTaskWoken

这个变量告诉调度器“刚才释放的信号量唤醒了一个更高优先级的任务”。如果你不调用 portYIELD_FROM_ISR,那个任务要等到下一个 tick 才能运行。我曾经因为这个 bug,导致高优先级任务延迟了整整 1 毫秒才响应中断。

陷阱 3:队列长度设置过小

UART 中断可能在一瞬间塞入几十个字节。如果队列长度只有 5,后面的数据全丢了。我建议队列长度至少是最大数据包长度的 2 倍。

4.6 知识体系图:中断与任务同步的核心逻辑

中断与任务同步流程 中断与任务同步:信号量 & 消息队列 UART 中断 xSemaphoreGiveFromISR() xSemaphoreTake() → 处理数据 xQueueSendFromISR() xQueueReceive() → 解析数据 核心原则 中断只做“释放”操作(Give/Send),不做“获取”操作(Take/Receive) 必须使用 FromISR 后缀 API,并检查 xHigherPriorityTaskWoken

4.7 小结

中断与任务同步,说白了就是“中断负责通知,任务负责干活”。信号量适合简单的事件通知,消息队列适合传递具体数据。记住三点:

  • 中断里只用 FromISR 版本 API
  • 检查 xHigherPriorityTaskWoken 并做上下文切换
  • 队列长度要留余量,防止数据丢失