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)与优先级反转

互斥锁和信号量有点像,但有个关键区别:谁上锁,谁解锁。互斥锁还支持优先级继承,能解决一个经典问题——优先级反转。

什么是优先级反转?我举个例子:

  1. 低优先级任务L拿到了互斥锁
  2. 高优先级任务H来了,想拿锁,但锁被L占着,H只能阻塞等待
  3. 这时候中优先级任务M开始运行,它不拿锁,但抢占了L的CPU
  4. 结果呢?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 消息队列:生产者-消费者模型

消息队列是任务间传递数据的标准方式。一个任务往里写,另一个任务往外读。这就是经典的生产者-消费者模型。

我画个图帮你理解:

生产者-消费者模型(消息队列) 生产者任务 消费者任务 消息队列 [msg1] [msg2] [msg3] ... FIFO 顺序 LOS_QueueWrite LOS_QueueRead 关键特性 • 队列满时,生产者可阻塞等待或直接返回 • 队列空时,消费者可阻塞等待或直接返回 • 支持优先级消息:高优先级消息插队到队首

消息队列的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,结果系统直接死锁。中断里要用信号量或事件来通知任务,让任务去处理锁操作。

好了,这四种同步通信机制就讲完了。你想想看,实际项目中哪种场景用哪个,心里应该有个数了。信号量管资源计数,互斥锁管临界区,事件管多条件同步,消息队列管数据传递。选对了工具,代码写起来就顺手多了。