3. 任务间同步与通信:信号量、互斥量、消息队列、事件标志组

做运动控制,说白了就是跟「并发」打交道。一个电机要转,一个编码器要读,一个通信口要收发数据,它们都在抢CPU的时间片。如果不管不顾,数据乱掉、电机抖动、甚至飞车,都是家常便饭。

我刚开始做多轴联动的时候,就吃过这个亏。两个任务同时写一个全局变量,结果位置指令被覆盖,电机直接冲过了限位。嗯,从那以后,我老老实实把同步和通信机制学了个透。

这一章,咱们就聊聊RTOS里最常用的四件套:信号量、互斥量、消息队列、事件标志组。它们各有各的脾气,用对了是神器,用错了是坑。

核心观点:任务间同步与通信,本质上是「谁先谁后」和「数据怎么传」的问题。选对工具,事半功倍。

3.1 信号量:最简单的同步开关

信号量,你可以把它想象成一个「令牌桶」。任务A往桶里放令牌,任务B从桶里拿令牌。拿不到令牌,任务B就乖乖等着。

在运动控制里,最常见的用法是:中断服务程序(ISR)释放信号量,任务等待信号量。比如编码器Z信号到来,ISR释放信号量,位置校准任务立刻被唤醒。

// 伪代码示例:编码器Z信号同步
sem_t z_sem;

void encoder_z_isr(void) {
    // 硬件中断来了,释放信号量
    sem_give(z_sem);
}

void calibration_task(void *arg) {
    while(1) {
        // 等待Z信号,拿不到就挂起
        sem_take(z_sem, WAIT_FOREVER);
        // 执行位置校准
        calibrate_position();
    }
}

这里有个细节:信号量分两种——计数信号量和二值信号量。计数信号量可以累积,适合「生产者-消费者」模式。二值信号量只有0和1,适合简单的同步。

我的经验:在ISR里释放信号量时,一定要用「从ISR调用」的专用API(比如xSemaphoreGiveFromISR)。否则,调度器可能崩溃。我曾经因为这个bug查了三天,最后发现是API用错了。

3.2 互斥量:解决资源争抢的利器

互斥量,本质上是一种特殊的二值信号量。但它有个关键特性——优先级继承。什么意思?

假设任务A(高优先级)和任务B(低优先级)都要访问同一个共享资源。任务B先拿到了互斥量,任务A只能等。这时候,如果有个中等优先级的任务C抢占了任务B,任务A就得等更久。这就是经典的「优先级反转」问题。

互斥量的优先级继承机制,会临时把任务B的优先级提升到任务A的水平,防止任务C插队。等任务B释放互斥量,优先级再降回去。

特性 互斥量 二值信号量
优先级继承 支持 不支持
递归获取 支持(同一任务可多次获取) 不支持
典型用途 保护共享资源(如全局变量、外设) 任务同步

我个人习惯,保护共享资源时只用互斥量,不用二值信号量。为什么?因为互斥量帮你自动处理了优先级反转,省心。

注意:不要在ISR里使用互斥量!ISR里不能阻塞等待,而互斥量的获取可能引起任务切换。ISR里请用信号量或直接发消息队列。

3.3 消息队列:任务间的数据管道

信号量和互斥量解决的是「同步」问题,但很多时候,我们需要传递数据。比如,上位机发来一个位置指令,运动控制任务要收到这个指令才能执行。

消息队列就是干这个的。它像一个FIFO管道,一个任务往里写,另一个任务往外读。数据可以是整数、结构体、甚至指针。

// 伪代码:消息队列传递位置指令
typedef struct {
    float target_position;
    float velocity;
    uint32_t timestamp;
} motion_cmd_t;

queue_t cmd_queue;

void command_receive_task(void *arg) {
    motion_cmd_t cmd;
    while(1) {
        // 阻塞等待指令
        queue_receive(&cmd_queue, &cmd, WAIT_FOREVER);
        // 执行运动规划
        motion_planner(&cmd);
    }
}

void can_isr_handler(void) {
    motion_cmd_t cmd;
    // 从CAN总线解析指令
    parse_can_message(&cmd);
    // 从ISR发送到队列
    queue_send_from_isr(&cmd_queue, &cmd);
}

消息队列有个好处:解耦。发送方和接收方不需要知道对方的存在,只需要知道队列的句柄。这在模块化设计中特别有用。

你想想看,如果每个模块都直接调用对方的函数,代码就成了一团乱麻。用消息队列,每个模块只管往队列里丢数据,谁处理、怎么处理,那是另一个任务的事。

避坑指南:我曾经把消息队列的深度设得太小,结果高频发送时数据被丢弃,电机出现了随机抖动。后来我把队列深度设为发送频率的2倍以上,问题解决。记住:队列深度要留余量。

3.4 事件标志组:多条件同步

有时候,一个任务要等好几个条件都满足才能继续。比如,启动一个运动序列,需要「急停按钮未按下」、「伺服使能已就绪」、「位置指令已到达」三个条件同时成立。

用信号量?你得等三个信号量,代码写起来很别扭。用事件标志组就简单了——每个条件对应一个bit,任务可以等「任意一个bit置位」或「所有bit都置位」。

// 伪代码:事件标志组
#define EVENT_ESTOP_CLEAR    (1 << 0)
#define EVENT_SERVO_READY    (1 << 1)
#define EVENT_CMD_ARRIVED    (1 << 2)

event_group_t motion_events;

void motion_start_task(void *arg) {
    EventBits_t bits;
    while(1) {
        // 等待三个事件全部发生
        bits = event_group_wait_bits(
            &motion_events,
            EVENT_ESTOP_CLEAR | EVENT_SERVO_READY | EVENT_CMD_ARRIVED,
            pdTRUE,  // 等待所有bit
            pdFALSE, // 不清除bit
            WAIT_FOREVER
        );
        // 开始运动
        start_motion_profile();
    }
}

事件标志组还有个妙用:你可以用它来广播状态变化。多个任务可以同时等待同一个事件组,一旦某个bit被置位,所有等待的任务都被唤醒。

嗯,这里要注意:事件标志组的bit数量有限(通常是24位或32位),别把每个小状态都映射成一个bit。我一般只用来表示「关键状态变化」,比如故障、就绪、完成。

总结一下我的选择原则:

  • 只需要同步,不传数据 → 信号量
  • 保护共享资源,防止优先级反转 → 互斥量
  • 需要传递数据,模块间解耦 → 消息队列
  • 多个条件同时等待,广播状态 → 事件标志组

3.5 知识体系图

下面这张图,帮你理清这四者的关系和使用场景。

任务间同步与通信机制 RTOS 内核 信号量 互斥量 消息队列 事件标志组 特性 • 计数/二值两种模式 • 适合ISR→任务同步 • 无优先级继承 特性 • 优先级继承 • 递归获取支持 • 保护共享资源 特性 • FIFO数据管道 • 支持任意数据类型 • 模块间解耦 特性 • 多条件同步 • 任意/全部模式 • 广播状态变化 使用场景:运动控制中的任务协调 编码器Z信号同步 共享位置变量保护 CAN指令传递 多条件启动序列

这张图把四个机制放在一起对比。你看,它们从内核出发,各自解决不同的问题。信号量管同步,互斥量管资源保护,消息队列管数据传递,事件标志组管多条件等待。搞懂了这些,你的运动控制代码会清晰很多。

最后说一句:别想着一个机制打天下。我见过有人用消息队列传空数据当信号量用,也有人用信号量保护共享资源。不是不能用,但总有更优雅的方式。选对工具,代码才漂亮。


公众号:蓝海资料掘金营,微信deep3321