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 实战中的几个坑
最后分享几个我踩过的坑,你遇到了能少走弯路:
- 中断频率太高,消息队列溢出——解决办法:队列长度按最坏情况算,再加一个丢包计数器用于调试。
- 信号量在ISR中释放,但任务优先级太低——任务可能被其他高优先级任务抢占,导致响应延迟。解决办法:把处理任务的优先级设高一些。
- 事件标志组的事件位被多个ISR共用——两个ISR设置同一个位,任务以为两个事件都发生了。解决办法:每个中断源用独立的事件位。
- 在ISR里用了阻塞函数——比如不小心调了semTake()。解决办法:写ISR时脑子里绷根弦,只调用文档里明确说“可在ISR中使用”的函数。
好了,ISR与任务通信的三种方式就讲到这里。说白了,核心就一句话:ISR只负责发通知,任务负责干活。把这个原则记牢了,中断设计就不会出大问题。