4. 任务同步与通信:信号量、互斥锁、事件与消息队列
各位同学,咱们今天聊点实在的。多任务系统里,任务之间怎么配合干活?怎么避免抢资源打架?这就是任务同步与通信要解决的问题。我在做第一个鸿蒙项目时,就因为没处理好同步,两个任务同时操作一个串口,结果数据全乱了。从那以后,我对这块内容就特别上心。
4.1 信号量(Semaphore)原理与API
信号量,说白了就是一个计数器。它用来控制多个任务对共享资源的访问。你可以把它想象成停车场的空位指示牌——有空位,车才能进去;没空位,就得等着。
LiteOS-M 里信号量分两种:
- 计数型信号量:资源数量可以大于1,比如缓冲池有5个缓冲区
- 二值信号量:只有0和1,相当于互斥锁的简化版
核心API就这几个:
// 创建信号量,count是初始可用资源数
UINT32 LOS_SemCreate(UINT16 count, UINT32 *semHandle);
// 申请信号量,timeout是等待超时(单位:tick)
UINT32 LOS_SemPend(UINT32 semHandle, UINT32 timeout);
// 释放信号量
UINT32 LOS_SemPost(UINT32 semHandle);
// 删除信号量
UINT32 LOS_SemDelete(UINT32 semHandle);
我的习惯:创建信号量时,count 一般设成资源总数。如果资源只有一个,就用二值信号量。我在调试一个传感器驱动时,发现信号量泄漏了——申请了没释放,结果任务全卡死。后来加了个超时机制,问题就解决了。
4.2 互斥锁(Mutex)与优先级反转
互斥锁和信号量有点像,但有个关键区别:谁上锁,谁解锁。互斥锁还支持优先级继承,能解决一个经典问题——优先级反转。
什么是优先级反转?我举个例子:
- 低优先级任务L拿到了互斥锁
- 高优先级任务H来了,想拿锁,但锁被L占着,H只能阻塞等待
- 这时候中优先级任务M开始运行,它不拿锁,但抢占了L的CPU
- 结果呢?H明明优先级最高,却被M和L一起拖住了
这问题我在一个实时控制项目里遇到过。一个电机控制任务优先级最高,但被一个打印日志的低优先级任务卡了整整200ms。电机直接抖起来了。
LiteOS-M 的互斥锁API:
// 创建互斥锁
UINT32 LOS_MuxCreate(UINT32 *muxHandle);
// 申请互斥锁
UINT32 LOS_MuxPend(UINT32 muxHandle, UINT32 timeout);
// 释放互斥锁
UINT32 LOS_MuxPost(UINT32 muxHandle);
// 删除互斥锁
UINT32 LOS_MuxDelete(UINT32 muxHandle);
避坑指南:我曾经在中断服务函数里调用了 LOS_MuxPend,结果系统直接挂掉。记住:互斥锁不能在中断中使用。中断里要用信号量或事件。
优先级反转的对比表:
| 特性 | 普通互斥锁 | 支持优先级继承的互斥锁 |
|---|---|---|
| 低优先级任务持有锁时 | 保持原优先级 | 临时提升到高优先级任务的优先级 |
| 中优先级任务抢占 | 可能发生,导致优先级反转 | 不会发生,因为低优先级任务已被提升 |
| 实时性 | 不可预测 | 可预测,有上界 |
4.3 事件(Event)标志组
事件标志组,你可以理解成一个位图。每个位代表一个事件。任务可以等待一个或多个事件发生。这比信号量灵活——信号量只能等一个资源,事件可以等任意组合。
LiteOS-M 的事件API:
// 创建事件标志组
UINT32 LOS_EventInit(PEVENT_CB_S eventCB);
// 等待事件,mode可以是:
// LOS_WAITMODE_AND:所有事件都到才唤醒
// LOS_WAITMODE_OR:任一事件到就唤醒
// LOS_WAITMODE_CLR:等待成功后清除事件
UINT32 LOS_EventRead(PEVENT_CB_S eventCB, UINT32 eventMask, UINT32 mode, UINT32 timeout);
// 写入事件
UINT32 LOS_EventWrite(PEVENT_CB_S eventCB, UINT32 events);
// 清除事件
UINT32 LOS_EventClear(PEVENT_CB_S eventCB, UINT32 events);
实际用法:我做一个多传感器采集系统时,用事件标志组来同步。传感器A、B、C各自采集完就写对应的事件位。主任务等三个位都置1(AND模式)后才开始融合数据。这样代码结构特别清晰。
4.4 消息队列:生产者-消费者模型
消息队列是任务间传递数据的标准方式。一个任务往里写,另一个任务往外读。这就是经典的生产者-消费者模型。
我画个图帮你理解:
消息队列的API:
// 创建消息队列,queueLen是队列深度,nodeSize是每条消息大小
UINT32 LOS_QueueCreate(const CHAR *queueName, UINT16 queueLen,
UINT32 *queueHandle, UINT32 flags, UINT16 nodeSize);
// 写消息(带拷贝)
UINT32 LOS_QueueWrite(UINT32 queueHandle, VOID *buffer,
UINT32 bufferSize, UINT32 timeout);
// 写消息(只写地址,不拷贝数据)
UINT32 LOS_QueueWriteCopy(UINT32 queueHandle, VOID *buffer,
UINT32 bufferSize, UINT32 timeout);
// 读消息
UINT32 LOS_QueueRead(UINT32 queueHandle, VOID *buffer,
UINT32 bufferSize, UINT32 timeout);
// 删除消息队列
UINT32 LOS_QueueDelete(UINT32 queueHandle);
一个完整的生产者-消费者示例:
#define QUEUE_LEN 10
#define MSG_SIZE sizeof(int)
UINT32 g_queueHandle;
// 生产者任务
VOID ProducerTask(VOID)
{
int data = 0;
while (1) {
data++;
// 写消息,如果队列满则等待100 ticks
LOS_QueueWrite(g_queueHandle, &data, MSG_SIZE, 100);
LOS_TaskDelay(10); // 模拟生产间隔
}
}
// 消费者任务
VOID ConsumerTask(VOID)
{
int data;
while (1) {
// 读消息,如果队列空则一直等待
LOS_QueueRead(g_queueHandle, &data, MSG_SIZE, LOS_WAIT_FOREVER);
// 处理数据
printf("Received: %d\n", data);
}
}
// 初始化
VOID AppInit(VOID)
{
LOS_QueueCreate("myQueue", QUEUE_LEN, &g_queueHandle, 0, MSG_SIZE);
// 创建生产者任务和消费者任务...
}
我的经验:消息大小设成4字节对齐,否则在Cortex-M上会有对齐异常。另外,如果消息体很大(比如几百字节),建议用 LOS_QueueWrite(只传地址),避免频繁拷贝。但要注意:发送方必须保证数据在接收方读完之前不会被释放。
4.5 四种同步机制对比
| 机制 | 适用场景 | 能否传数据 | 中断中可用 | 典型问题 |
|---|---|---|---|---|
| 信号量 | 资源计数、任务同步 | 否 | 可以(Post) | 信号量泄漏 |
| 互斥锁 | 保护共享资源 | 否 | 不可以 | 优先级反转 |
| 事件标志组 | 多条件同步 | 否 | 可以(Write) | 事件位冲突 |
| 消息队列 | 任务间数据传递 | 是 | 可以(Write) | 队列溢出、数据拷贝开销 |
再强调一次:中断里绝对不能用互斥锁。我曾经在UART中断里调了LOS_MuxPend,结果系统直接死锁。中断里要用信号量或事件来通知任务,让任务去处理锁操作。
好了,这四种同步通信机制就讲完了。你想想看,实际项目中哪种场景用哪个,心里应该有个数了。信号量管资源计数,互斥锁管临界区,事件管多条件同步,消息队列管数据传递。选对了工具,代码写起来就顺手多了。