4. ISR与任务通信:使用信号量、消息队列、事件标志组在ISR中发送通知

中断服务程序(ISR)里最让人头疼的问题是什么?我个人觉得,不是中断嵌套,也不是寄存器保存,而是——怎么把中断里收到的数据,安全地交给任务去处理。

ISR里不能调用printf,不能malloc,更不能做任何阻塞操作。那数据怎么传出去?VxWorks给了我们三样武器:信号量、消息队列、事件标志组。今天我就把这三种方式掰开揉碎了讲清楚。

4.1 为什么ISR不能直接处理数据?

先想一个问题:中断来了,你直接在ISR里把数据算完、存好、甚至发出去,不行吗?

行,但很危险。ISR执行时间越长,系统响应越慢。更严重的是,ISR里如果调用了可能阻塞的函数,整个系统可能直接挂掉。我见过一个项目,工程师在ISR里调用了semTake(),结果中断优先级高,任务调度不了,死锁了。查了两天才找到原因。

所以,ISR的黄金法则是:快进快出,只做最必要的事。把数据收下来,发个信号给任务,让任务去慢慢处理。

4.2 信号量:最简单的通知方式

信号量在ISR里最常用的场景是:通知一个任务“有活干了”

比如一个串口中断,每收到一个字节,ISR就把数据放进缓冲区,然后释放一个二值信号量。等待这个信号量的任务被唤醒,从缓冲区取数据去处理。

核心要点:ISR中只能使用semGive(),绝对不能使用semTake()。semGive()是非阻塞的,适合中断上下文。

代码示例:

/* 全局信号量 */
SEM_ID g_serialSem;

/* 初始化任务 */
void initTask(void)
{
    /* 创建二值信号量,初始为0 */
    g_serialSem = semBCreate(SEM_Q_FIFO, SEM_EMPTY);
    
    /* 创建处理任务 */
    taskSpawn("serialTask", 100, 0, 20000, serialTask, 0,0,0,0,0,0,0,0,0,0);
}

/* ISR - 串口接收中断 */
void serialIsr(void)
{
    /* 读取数据到缓冲区(略) */
    
    /* 通知任务处理 */
    semGive(g_serialSem);
}

/* 任务 - 处理串口数据 */
void serialTask(void)
{
    while(1)
    {
        /* 等待信号量 */
        semTake(g_serialSem, WAIT_FOREVER);
        
        /* 从缓冲区取数据并处理 */
        processSerialData();
    }
}

我的经验:用信号量做ISR通知时,记得把信号量初始化为空(SEM_EMPTY)。这样任务一开始就会阻塞等待,直到ISR第一次释放信号量。我曾经犯过这个错,初始化成满的,结果任务一启动就以为有数据,读了个空缓冲区。

4.3 消息队列:带数据的通知

信号量只能通知“有事了”,但具体什么事,任务还得自己去查。如果ISR想把数据直接传给任务,消息队列是更好的选择。

消息队列在ISR里只能用msgQSend(),而且要注意:消息队列的长度要足够大,否则中断频繁时可能丢消息。

代码示例:

/* 全局消息队列 */
MSG_Q_ID g_msgQ;

/* 初始化 */
void initTask(void)
{
    /* 创建消息队列,最多50条消息,每条消息32字节 */
    g_msgQ = msgQCreate(50, 32, MSG_Q_FIFO);
}

/* ISR - 按键中断 */
void keyPressIsr(void)
{
    UINT32 keyCode;
    
    /* 读取按键码 */
    keyCode = readKeyRegister();
    
    /* 发送消息给任务 */
    msgQSend(g_msgQ, (char *)&keyCode, sizeof(keyCode), 
             NO_WAIT, MSG_PRI_NORMAL);
}

/* 任务 - 处理按键 */
void keyTask(void)
{
    UINT32 keyCode;
    
    while(1)
    {
        /* 接收消息 */
        msgQReceive(g_msgQ, (char *)&keyCode, sizeof(keyCode), 
                    WAIT_FOREVER);
        
        /* 处理按键 */
        handleKeyPress(keyCode);
    }
}

注意:msgQSend()在ISR中调用时,最后一个参数必须传NO_WAIT。因为ISR里不能阻塞等待队列空位。如果队列满了,消息会直接丢弃。所以队列长度要按最坏情况来设计。

我个人习惯在消息队列满的时候,加一个统计计数器。这样调试时能发现是不是队列长度不够。嗯,这个习惯帮我抓过好几次bug。

4.4 事件标志组:多条件通知

有时候一个任务要等多个中断事件。比如一个数据采集任务,要等“定时器中断”和“DMA完成中断”都发生了,才开始处理。这时候事件标志组就派上用场了。

事件标志组在ISR里只能用eventSend()。它可以设置一个或多个事件位,任务可以等待“与”条件(所有位都置位)或“或”条件(任意位置位)。

代码示例:

/* 事件标志组 */
EVENT_GROUP_ID g_eventGroup;

/* 事件位定义 */
#define EVENT_TIMER    0x01
#define EVENT_DMA_DONE 0x02

/* 初始化 */
void initTask(void)
{
    g_eventGroup = eventGroupCreate(0);
}

/* 定时器中断ISR */
void timerIsr(void)
{
    eventSend(g_eventGroup, EVENT_TIMER);
}

/* DMA完成中断ISR */
void dmaDoneIsr(void)
{
    eventSend(g_eventGroup, EVENT_DMA_DONE);
}

/* 数据采集任务 */
void dataAcqTask(void)
{
    UINT32 events;
    
    while(1)
    {
        /* 等待两个事件都发生 */
        events = eventReceive(EVENT_TIMER | EVENT_DMA_DONE,
                              EVT_FLAGS_AND,
                              WAIT_FOREVER,
                              WAIT_FOREVER);
        
        /* 两个中断都到了,开始处理数据 */
        processData();
    }
}

避坑指南:我曾经在ISR里用eventSend()时,忘了事件位是32位的。结果两个ISR设置的是同一个位,任务永远等不到第二个事件。后来我规定:每个中断源用独立的事件位,绝不共用。这个习惯一直保留到现在。

4.5 三种方式的对比与选择

说了这么多,到底什么时候用哪个?我整理了一个表格,方便你对照选择:

通信方式 适用场景 数据传递 ISR中可用函数 注意事项
信号量 简单通知“有活干了” 不传数据,只发信号 semGive() 二值信号量最常用
消息队列 需要传递具体数据 传递数据内容 msgQSend() 队列长度要足够,用NO_WAIT
事件标志组 多中断条件组合 传递事件位 eventSend() 事件位不要重复

你想想看,如果只是通知任务“缓冲区有数据了”,信号量就够了。如果要把按键码、传感器值这些数据传过去,消息队列更直接。如果任务要等好几个中断都到了才开始干活,事件标志组最合适。

4.6 实战中的几个坑

最后分享几个我踩过的坑,你遇到了能少走弯路:

  1. 中断频率太高,消息队列溢出——解决办法:队列长度按最坏情况算,再加一个丢包计数器用于调试。
  2. 信号量在ISR中释放,但任务优先级太低——任务可能被其他高优先级任务抢占,导致响应延迟。解决办法:把处理任务的优先级设高一些。
  3. 事件标志组的事件位被多个ISR共用——两个ISR设置同一个位,任务以为两个事件都发生了。解决办法:每个中断源用独立的事件位。
  4. 在ISR里用了阻塞函数——比如不小心调了semTake()。解决办法:写ISR时脑子里绷根弦,只调用文档里明确说“可在ISR中使用”的函数。

好了,ISR与任务通信的三种方式就讲到这里。说白了,核心就一句话:ISR只负责发通知,任务负责干活。把这个原则记牢了,中断设计就不会出大问题。