第3章:实时操作系统(RTOS)基础:FreeRTOS任务创建与调度、任务优先级与堆栈配置、中断管理

各位同学,咱们今天聊聊实时操作系统。说实话,我刚入行那会儿,对RTOS也是又爱又恨。爱的是它能让多任务并行,恨的是稍不留神就出bug。但做运动控制,你绕不开它。

为什么?因为运动控制对实时性要求极高。你想想看,电机每转一圈可能要发几千个脉冲,每个脉冲的时机都不能错。用裸机轮询?累死你也搞不定。这时候,RTOS就是你的救星。

3.1 FreeRTOS任务创建:从零开始跑一个任务

任务,说白了就是一个无限循环的函数。它有自己的栈空间,有自己的优先级。我习惯把任务想象成一个独立的小人,各干各的活。

创建任务的API长这样:

// 任务函数原型
void vTaskFunction(void *pvParameters)
{
    // 任务初始化代码
    for(;;)
    {
        // 任务主体代码
        // 这里放你的运动控制逻辑
        vTaskDelay(pdMS_TO_TICKS(10)); // 延时10ms
    }
}

// 创建任务
BaseType_t xTaskCreate(
    TaskFunction_t pvTaskCode,        // 任务函数指针
    const char * const pcName,        // 任务名称(调试用)
    configSTACK_DEPTH_TYPE usStackDepth, // 堆栈深度(单位:字)
    void *pvParameters,               // 传递给任务的参数
    UBaseType_t uxPriority,           // 任务优先级
    TaskHandle_t *pxCreatedTask       // 任务句柄(输出)
);

这里有个坑,我踩过。堆栈深度单位是,不是字节。在32位MCU上,一个字是4字节。所以如果你要分配1024字节的栈,深度要写256。我曾经因为搞混这个,程序跑着跑着就莫名其妙复位了。查了两天才发现是栈溢出。

注意:堆栈大小一定要留余量。我一般至少多给30%。运动控制任务里如果有浮点运算、函数嵌套调用,栈消耗会很大。保守点,给个512字起步。

3.2 任务调度:谁先跑,谁后跑?

FreeRTOS是个抢占式调度器。什么意思?就是高优先级的任务可以随时打断低优先级的任务。这就像公司里,老板(高优先级)一说话,员工(低优先级)就得闭嘴。

调度策略其实就三种:

  • 抢占式调度:高优先级就绪,立刻切换
  • 时间片轮转:同优先级任务轮流跑,每人一个时间片
  • 协作式调度:任务主动让出CPU才切换(这个用得少)

我做运动控制时,通常这样分配优先级:

优先级 任务类型 典型任务
最高(5) 硬实时任务 电机电流环、编码器读取
高(4) 软实时任务 位置环、速度环计算
中(3) 通信任务 EtherCAT、CAN总线处理
低(2) 人机交互 按键扫描、LCD刷新
最低(1) 后台任务 日志记录、状态统计

嗯,这里要注意。优先级不是越高越好。你把所有任务都设成最高优先级,那跟裸机轮询没区别。调度器会频繁切换,反而降低效率。

3.3 堆栈配置:给任务一个安稳的家

每个任务都有自己的栈。栈里存什么?局部变量、函数调用返回地址、中断现场。如果栈不够大,程序就会跑飞。

我教大家一个估算方法:

  1. 先给一个保守值(比如512字)
  2. 跑起来后,用 uxTaskGetStackHighWaterMark() 查看剩余栈空间
  3. 根据剩余量调整,留出30%余量
// 查看栈剩余空间
UBaseType_t uxHighWaterMark;
uxHighWaterMark = uxTaskGetStackHighWaterMark(xTaskHandle);
printf("剩余栈空间: %d 字\n", uxHighWaterMark);
小技巧:我习惯在任务函数开头定义一个大的局部数组,比如 uint8_t ucDummy[100],然后memset成0xAA。跑一段时间后检查这个数组有没有被改写。如果有,说明栈溢出了。这招虽然土,但很管用。

3.4 中断管理:别让中断抢了任务的饭碗

中断是RTOS里最敏感的话题。FreeRTOS里,中断服务函数(ISR)要遵守几个铁律:

  • ISR里不能调用阻塞API(比如 vTaskDelay
  • ISR里要用带 FromISR 后缀的API
  • ISR要尽量短,把耗时操作交给任务

举个例子,编码器中断来了,你该怎么做?

// 错误做法:在ISR里做大量计算
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    // 计算速度、位置... 千万别!
    // 这会阻塞其他中断,导致系统抖动
}

// 正确做法:只发信号,让任务去处理
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // 通知运动控制任务
    vTaskNotifyGiveFromISR(xMotionTaskHandle, 
                           &xHigherPriorityTaskWoken);
    // 如果需要上下文切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

我曾经在一个项目里,把整个PID计算都放在中断里。结果呢?电机一跑起来,系统就卡死。后来才发现,中断占用了太多时间,导致低优先级任务永远得不到CPU。这就是典型的「中断饿死任务」。

核心原则:中断只做最紧急的事(读寄存器、清标志、发信号),剩下的交给任务。中断是「通知」,任务才是「干活」的。

3.5 实战中的避坑指南

最后,分享几个我这些年踩过的坑:

  • 优先级反转:低优先级任务持有信号量,高优先级任务等信号量,中间优先级任务插队。解决办法:用优先级继承或使用互斥量。
  • 看门狗喂食:别在低优先级任务里喂狗。万一高优先级任务卡住,狗就超时了。我习惯在空闲任务里喂狗。
  • 任务栈对齐:有些MCU要求栈8字节对齐。创建任务时注意堆栈地址。

好了,这一章的内容就这些。记住,RTOS不是万能的,但用好了,你的运动控制系统会稳如老狗。下一章咱们聊聊更深入的东西。

FreeRTOS 实时运动控制核心架构 任务创建 xTaskCreate() 任务函数定义 参数传递 任务调度 抢占式调度 时间片轮转 优先级管理 堆栈配置 栈深度设置 水位监测 溢出保护 中断管理 ISR编写规范 FromISR API 中断与任务协作 运动控制应用 电流环任务(最高优先级) 速度环任务(高优先级) 位置环任务(中优先级) 图:FreeRTOS实时运动控制系统核心模块关系图

本章小结:

  • 任务创建要合理分配堆栈,留足余量
  • 优先级设计遵循「硬实时 > 软实时 > 通信 > 人机交互」原则
  • 中断只做通知,不做计算
  • 善用 uxTaskGetStackHighWaterMark() 监控栈使用

专注资料整理