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 知识体系图
下面这张图,帮你理清这四者的关系和使用场景。
这张图把四个机制放在一起对比。你看,它们从内核出发,各自解决不同的问题。信号量管同步,互斥量管资源保护,消息队列管数据传递,事件标志组管多条件等待。搞懂了这些,你的运动控制代码会清晰很多。
最后说一句:别想着一个机制打天下。我见过有人用消息队列传空数据当信号量用,也有人用信号量保护共享资源。不是不能用,但总有更优雅的方式。选对工具,代码才漂亮。
公众号:蓝海资料掘金营,微信deep3321