9. 任务间数据同步:队列、信号量、互斥锁在插补中的应用

各位同学,咱们今天聊一个非常实际的问题——任务间数据同步。

做运动控制插补,说白了就是多个任务在抢着干活。插补任务要算位置,速度规划任务要算速度,IO任务要监控限位,通信任务要跟上位机聊天。这些任务之间怎么传递数据?怎么保证数据不乱?这就是我们今天要解决的核心问题。

我个人习惯把RTOS里的同步机制比作「交通规则」。没有红绿灯,路口就乱套了。没有队列、信号量、互斥锁,你的插补系统就会出各种诡异的问题——比如电机突然抖一下,或者位置跑偏了。

9.1 队列:插补数据的「流水线」

队列,说白了就是一个先进先出的缓冲区。在插补系统里,我经常用它来传递「插补点数据」。

你想想看,插补任务算出来的位置点,要送给伺服驱动任务去执行。这两个任务跑在不同的优先级上,插补任务可能算得很快,伺服任务执行得慢。如果没有队列,数据就会丢失或者覆盖。

队列的核心价值:解耦生产者和消费者,让它们各自按自己的节奏工作。

我在项目中遇到过这样一个场景:一个五轴插补系统,插补周期是1ms,但伺服驱动的执行周期是2ms。如果直接用全局变量传数据,插补任务写一次,伺服任务读一次,中间就会丢数据。用队列就完美解决了——插补任务只管往队列里塞数据,伺服任务只管从队列里取数据,互不干扰。

// 队列句柄
QueueHandle_t xInterpPointQueue;

// 插补任务:生产者
void vInterpolationTask(void *pvParameters) {
    InterpPoint_t pt;
    while(1) {
        // 计算下一个插补点
        pt.x = current_x + step_x;
        pt.y = current_y + step_y;
        pt.feedrate = current_feedrate;
        
        // 发送到队列,等待10ms
        if(xQueueSend(xInterpPointQueue, &pt, pdMS_TO_TICKS(10)) != pdPASS) {
            // 队列满了,说明伺服任务处理不过来
            // 我一般会在这里做降速处理
            vReduceFeedrate();
        }
        
        vTaskDelay(pdMS_TO_TICKS(1)); // 1ms插补周期
    }
}

// 伺服任务:消费者
void vServoDriveTask(void *pvParameters) {
    InterpPoint_t pt;
    while(1) {
        // 从队列接收,阻塞等待
        if(xQueueReceive(xInterpPointQueue, &pt, portMAX_DELAY) == pdPASS) {
            // 执行位置控制
            vSetPositionTarget(pt.x, pt.y);
            vSetFeedrate(pt.feedrate);
        }
    }
}

小技巧:队列长度怎么设?我一般按「最坏情况」来算。比如插补任务1ms产生一个点,伺服任务2ms消费一个点,那队列长度至少设2个。但为了安全,我会设4-8个,留点余量。

9.2 信号量:任务间的「发令枪」

信号量,说白了就是一个计数器。它用来通知某个任务「你可以干活了」。

在插补系统里,我经常用信号量来做「事件通知」。比如插补任务算完一段轨迹后,通知IO任务去检查限位开关;或者速度规划任务算完速度曲线后,通知插补任务开始插补。

嗯,这里要注意:信号量分两种——二值信号量和计数信号量。二值信号量就像一把枪,只能开一枪;计数信号量就像一盒子弹,可以开多枪。

避坑指南:我曾经在一个项目中,用二值信号量做「多次事件通知」,结果发现信号量只能被取一次,后面的通知全丢了。后来改成计数信号量才解决问题。

// 信号量句柄
SemaphoreHandle_t xTrajectoryDoneSem;

// 插补任务:通知IO任务检查限位
void vInterpolationTask(void *pvParameters) {
    while(1) {
        // 计算轨迹...
        vCalculateTrajectory();
        
        // 轨迹计算完成,通知IO任务
        xSemaphoreGive(xTrajectoryDoneSem);
        
        // 继续下一个轨迹段
    }
}

// IO任务:等待通知
void vIOTask(void *pvParameters) {
    while(1) {
        // 等待插补任务的通知
        if(xSemaphoreTake(xTrajectoryDoneSem, portMAX_DELAY) == pdPASS) {
            // 检查限位开关
            if(vCheckLimitSwitch() == LIMIT_TRIGGERED) {
                // 触发急停
                vEmergencyStop();
            }
        }
    }
}

9.3 互斥锁:保护共享资源的「门禁」

互斥锁,说白了就是一把锁。谁拿到锁,谁就能访问共享资源。其他人只能等着。

在插补系统里,互斥锁最常用的场景是保护「全局配置参数」。比如插补速度、加速度、加加速度这些参数,上位机随时可能修改,而插补任务又在实时读取。如果没有互斥锁,就会出现「读到一半的数据被修改」这种问题。

重要提醒:互斥锁一定要「谁拿谁放」,而且拿锁的时间要尽量短。我见过有人拿锁后做复杂的数学运算,结果导致高优先级任务被阻塞,系统响应变慢。

// 互斥锁句柄
SemaphoreHandle_t xConfigMutex;

// 配置结构体
typedef struct {
    float max_speed;
    float max_accel;
    float max_jerk;
} MotionConfig_t;

MotionConfig_t g_config;

// 上位机通信任务:修改配置
void vCommTask(void *pvParameters) {
    MotionConfig_t new_config;
    while(1) {
        // 接收上位机的新配置
        if(vReceiveConfig(&new_config) == pdPASS) {
            // 拿锁
            if(xSemaphoreTake(xConfigMutex, pdMS_TO_TICKS(100)) == pdPASS) {
                // 更新配置
                g_config = new_config;
                // 放锁
                xSemaphoreGive(xConfigMutex);
            } else {
                // 拿锁失败,说明插补任务正在使用配置
                // 我一般会重试或者丢弃这次更新
                vLogError("Failed to update config");
            }
        }
    }
}

// 插补任务:读取配置
void vInterpolationTask(void *pvParameters) {
    float speed, accel;
    while(1) {
        // 拿锁
        xSemaphoreTake(xConfigMutex, portMAX_DELAY);
        speed = g_config.max_speed;
        accel = g_config.max_accel;
        xSemaphoreGive(xConfigMutex);
        
        // 使用配置进行计算
        vCalculateWithConfig(speed, accel);
    }
}

9.4 三种机制的对比与选择

说了这么多,到底什么时候用队列,什么时候用信号量,什么时候用互斥锁?我给大家总结一下:

机制 适用场景 典型应用 注意事项
队列 数据传递(生产者-消费者) 插补点传递、指令传递 队列长度要合理,满了要处理
信号量 事件通知、资源计数 轨迹完成通知、缓冲区可用通知 二值 vs 计数,选对类型
互斥锁 保护共享资源 配置参数保护、共享数据结构 拿锁时间要短,防止死锁

我个人习惯是:能不用锁就不用锁,能用队列就用队列。为什么?因为队列天然就是「无锁」的,不会出现死锁、优先级反转这些问题。信号量次之,互斥锁最后考虑。

9.5 实战中的避坑指南

做插补系统这么多年,我踩过不少坑。这里分享几个典型的:

  • 队列溢出:我曾经在一个高速插补系统里,队列设得太小,结果插补任务比伺服任务快太多,队列满了之后数据丢失,电机直接飞车。后来加了「队列满降速」的逻辑才解决。
  • 信号量丢失:有一次我用二值信号量做「多次事件通知」,结果发现信号量只能被取一次。后来改成计数信号量,每次通知前先检查信号量值,避免重复通知。
  • 互斥锁死锁:这个最坑。两个任务互相等对方的锁,结果谁也动不了。我后来养成了一个习惯——拿锁顺序必须一致,而且拿锁后尽快释放。

我的经验:在调试阶段,可以加一些「超时检测」。比如拿锁超过一定时间没拿到,就打印日志或者触发断言。这样能快速定位问题。

9.6 知识体系总览

最后,我用一张图来总结今天的内容。这张图展示了插补系统中任务间数据同步的完整框架:

插补系统任务间数据同步框架 插补任务 生产者 伺服任务 消费者 IO任务 事件响应 通信任务 配置修改 队列 插补点数据传递 信号量 事件通知 互斥锁 配置参数保护 图例: 数据流(队列) 事件流(信号量) 资源访问(互斥锁) 注:队列用于数据传递,信号量用于事件通知,互斥锁用于保护共享资源

这张图里,你可以看到三种同步机制在插补系统中的典型位置。队列连接插补任务和伺服任务,信号量连接插补任务和IO任务,互斥锁保护通信任务和插补任务之间的共享配置。

好了,今天的内容就到这里。记住一句话:同步机制选对了,系统就稳了一半。选错了,调试能调到你怀疑人生。