一、前言

在 STM32 裸机开发中,我们经常会把程序写成:

while (1)
{
    Remote_Read();

    IMU_Read();

    PID_Calculate();

    Motor_Output();
}

这种方式在功能比较简单的时候完全可以工作。

但当机器人系统逐渐复杂之后,例如加入:

  • 遥控器
  • IMU
  • CAN 电机
  • PID 控制
  • 上位机通信
  • 云台控制
  • 底盘控制
  • 机械臂控制

所有程序都堆在一个 while(1) 里面就会越来越难维护。

FreeRTOS 的核心作用之一,就是把这些功能拆分成多个任务(Task),并通过 Queue、Semaphore、Mutex 等机制让任务之间进行通信和同步。

本节先学习最基础、也是机器人项目中最常用的一部分:

Task
↓
Delay
↓
固定周期任务
↓
Queue
↓
任务之间的数据通信

二、FreeRTOS 中的 Task

2.1 什么是 Task?

可以简单理解为:

Task 就是一个可以被 FreeRTOS 独立调度的小程序。

例如机器人可以设计成:

RemoteTask       遥控器任务
IMUTask          IMU任务
ControlTask      控制任务
CANTask          CAN任务
DebugTask        调试任务

每个任务负责自己的功能。

例如:

void RemoteTask(void *argument)
{
    while (1)
    {
        Remote_Read();
    }
}

三、xTaskCreate():创建任务

创建 Task 最常用的 API 是:

xTaskCreate();

最常见的形式:

xTaskCreate(
    RemoteTask,
    "Remote",
    256,
    NULL,
    5,
    NULL
);

它的作用就是:

创建一个名为 Remote 的 FreeRTOS 任务,让它执行 RemoteTask()


3.1 第一个参数:任务函数

RemoteTask

表示:

这个 Task 要执行哪个函数。

任务函数通常写成:

void RemoteTask(void *argument)
{
    while (1)
    {
        Remote_Read();
    }
}

FreeRTOS 的任务函数一般采用:

void XXXTask(void *argument)

这种形式。


3.2 第二个参数:任务名字

"Remote"

这是 Task 的名字。

主要用于调试、查看系统中的任务。

例如:

Remote
IMU
Control
CAN
Debug

以后通过调试工具查看 FreeRTOS 任务时,可以很容易知道每个任务负责什么。


3.3 第三个参数:任务栈大小

256

这个参数表示 Task 的 Stack Depth。

需要注意:

这里通常不是直接表示 256 字节,而是表示 256 个 StackType_t

例如:

StackType_t = 4 Bytes

那么:

256 × 4 = 1024 Bytes

即大约 1 KB 栈空间。

栈太小会怎么样?

可能发生 Stack Overflow,导致:

  • 程序异常
  • HardFault
  • 任务跑飞
  • 数据莫名其妙被修改
  • 系统偶发崩溃

栈太大会怎么样?

不会因为“大”本身就出错,但是会浪费 STM32 有限的 RAM。

因此 Task 栈大小不能简单地“越大越好”。

后续可以通过:

uxTaskGetStackHighWaterMark();

观察任务实际栈使用情况,再进行优化。


3.4 第四个参数:传给 Task 的参数

NULL

表示:

当前没有额外的数据需要传给 Task。

例如:

xTaskCreate(
    MotorTask,
    "Motor",
    512,
    &motor1,
    5,
    NULL
);

这里:

&motor1

会传给:

void MotorTask(void *argument)

中的:

argument

所以可以通过 Task 参数,让同一个任务函数服务于不同对象。


3.5 第五个参数:任务优先级

例如:

5

表示 Task 的优先级。

例如:

ControlTask    7
IMUTask        6
RemoteTask     5
DebugTask      2

通常优先级数字越大,优先级越高。

但是需要注意:

高优先级 Task 并不意味着它永远占用 CPU。

只要高优先级任务进入阻塞状态,例如调用:

vTaskDelay();

那么 CPU 就可以运行其他任务。


3.6 第六个参数:Task 句柄

例如:

TaskHandle_t RemoteTaskHandle;

创建:

xTaskCreate(
    RemoteTask,
    "Remote",
    256,
    NULL,
    5,
    &RemoteTaskHandle
);

这个句柄可以理解成:

Task 的“身份证”。

以后可以通过它控制这个 Task,例如:

vTaskSuspend(RemoteTaskHandle);

或者:

vTaskResume(RemoteTaskHandle);

如果当前不需要控制这个 Task,可以直接:

NULL

四、vTaskDelay():让当前 Task 暂时阻塞

最常见写法:

vTaskDelay(pdMS_TO_TICKS(10));

它的作用是:

让当前 Task 暂时进入阻塞状态,并把 CPU 让给其他任务。

例如:

void RemoteTask(void *argument)
{
    while (1)
    {
        Remote_Read();

        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

运行过程可以理解为:

读取遥控器
    ↓
阻塞 10ms
    ↓
继续读取
    ↓
阻塞 10ms
    ↓
继续读取

4.1 为什么需要 Delay?

如果写成:

void RemoteTask(void *argument)
{
    while (1)
    {
        Remote_Read();
    }
}

那么 Task 会不断执行:

Remote_Read()
Remote_Read()
Remote_Read()
Remote_Read()
...

如果它优先级比较高,就可能长时间占用 CPU。

而:

vTaskDelay();

会让当前 Task 主动进入阻塞状态,让其他 Task 获得运行机会。


4.2 pdMS_TO_TICKS() 是什么?

例如:

vTaskDelay(pdMS_TO_TICKS(10));

表示:

延时 10ms。

vTaskDelay() 本身使用的是 FreeRTOS Tick,因此使用:

pdMS_TO_TICKS()

可以把毫秒转换成对应的 Tick。

相比直接写:

vTaskDelay(10);

更容易阅读,也更不容易因为 Tick 配置不同而产生误解。


五、vTaskDelayUntil():固定周期控制

普通的:

vTaskDelay();

更像:

执行
↓
等待一段时间
↓
执行
↓
等待一段时间

如果任务本身需要一定执行时间,那么实际的任务周期还会受到执行时间影响。

例如:

while (1)
{
    PID_Calculate();

    vTaskDelay(pdMS_TO_TICKS(1));
}

如果:

PID_Calculate() 执行时间 = 0.3ms

那么一次循环可能接近:

0.3ms + 1ms

所以不一定严格是 1ms 周期。


5.1 固定周期任务应该使用 vTaskDelayUntil()

典型写法:

void ControlTask(void *argument)
{
    TickType_t lastWakeTime;

    lastWakeTime = xTaskGetTickCount();

    while (1)
    {
        PID_Calculate();

        vTaskDelayUntil(
            &lastWakeTime,
            pdMS_TO_TICKS(1)
        );
    }
}

可以理解为:

让这个 Task 按照固定的时间节拍运行。

例如目标周期为 1ms:

0ms      PID
1ms      PID
2ms      PID
3ms      PID
4ms      PID

5.2 vTaskDelay() 和 vTaskDelayUntil() 的区别

API主要用途
vTaskDelay()普通的暂时阻塞
vTaskDelayUntil()固定周期任务

可以简单记忆:

vTaskDelay()
= 做完事情,再等一段时间

vTaskDelayUntil()
= 按照固定节拍运行

机器人中的:

  • PID 控制
  • 姿态控制
  • 速度环
  • 位置环

通常更适合使用固定周期设计。


六、Queue:任务之间的数据通道

当多个 Task 出现以后,一个很现实的问题就是:

一个任务得到的数据,怎么交给另一个任务?

例如:

RemoteTask
    ↓
读取遥控器

ControlTask
    ↓
读取遥控数据并进行控制

可以使用 FreeRTOS Queue。

可以把 Queue 想象成:

一个任务之间传递数据的“盒子”。


七、xQueueCreate():创建 Queue

例如:

QueueHandle_t RemoteQueue;

RemoteQueue = xQueueCreate(
    1,
    sizeof(RemoteData_t)
);

xQueueCreate() 有两个核心参数。


7.1 第一个参数:Queue 长度

1

表示:

这个 Queue 最多存放多少个元素。

这里表示:

最多存 1 份 RemoteData_t

7.2 第二个参数:每个元素的大小

sizeof(RemoteData_t)

例如:

typedef struct
{
    int16_t vx;
    int16_t vy;
    int16_t wz;

} RemoteData_t;

那么:

sizeof(RemoteData_t)

就是一份遥控器数据需要的内存大小。

所以:

xQueueCreate(
    1,
    sizeof(RemoteData_t)
);

可以翻译为:

创建一个最多保存 1 份遥控器数据的 Queue。


八、为什么很多机器人状态数据只保存最新值?

遥控器数据通常属于“状态”。

例如:

10ms前:vx = 100
现在:  vx = 200

控制系统通常更关心:

当前 vx = 200

而不是把过去的数据排队慢慢处理:

100 → 120 → 150 → 180 → 200

所以很多状态类数据会采用:

Queue 长度 = 1
+
xQueueOverwrite()

九、为什么不用全局变量?

最简单的 STM32 程序中,当然可以使用全局变量:

RemoteData_t g_remote;

然后:

RemoteTask
    ↓
修改 g_remote

ControlTask
    ↓
读取 g_remote

这种方法简单,而且很多情况下完全可用。

但在大型机器人系统中,如果所有模块都依赖大量全局变量:

g_remote
g_imu
g_motor
g_vision
g_arm
g_pc

就容易出现:

谁负责修改?

谁负责读取?

什么时间修改?

会不会同时读写?

数据到底从哪里流向哪里?

Queue 的一个重要价值就是明确地定义:

数据生产者
    ↓
Queue
    ↓
数据消费者

这样任务之间的数据流更加清晰。

需要注意:

并不是所有数据都必须使用 Queue。

简单的系统状态、只读参数、某些共享状态仍然可以使用全局变量或共享结构体。应该根据数据的性质选择合适的方法。


十、xQueueSend():向 Queue 发送数据

基本写法:

RemoteData_t remote;

xQueueSend(
    RemoteQueue,
    &remote,
    0
);

三个核心参数:

RemoteQueue
→ 往哪个 Queue 发送

&remote
→ 发送什么数据

0
→ Queue 满了以后等待多久

10.1 第一个参数

RemoteQueue

表示:

要把数据发送到哪个 Queue。


10.2 第二个参数

&remote

表示:

要发送的数据所在的地址。

FreeRTOS 会根据 Queue 创建时指定的数据大小,把数据复制到 Queue 中。

常见形式:

xQueueSend(
    Queue,
    &data,
    ...
);

10.3 第三个参数:阻塞时间

例如:

xQueueSend(
    RemoteQueue,
    &remote,
    0
);

表示:

Queue 满了就立即返回,不等待。

而:

xQueueSend(
    RemoteQueue,
    &remote,
    pdMS_TO_TICKS(10)
);

表示:

Queue 满了以后,最多等待 10ms。

还可以:

portMAX_DELAY

表示:

可以一直阻塞等待,直到有空间。


十一、为什么实时控制中不应该随便长时间等待?

假设遥控器任务:

10ms 更新一次

但是你写:

xQueueSend(
    RemoteQueue,
    &remote,
    pdMS_TO_TICKS(100)
);

如果 Queue 满了:

发送
 ↓
Queue满
 ↓
等待
 ↓
最多100ms

那么遥控数据更新可能被拖慢。

实时控制系统通常更关注:

及时性。

因此要根据具体数据的性质决定到底要不要等待。


十二、xQueueOverwrite():覆盖旧数据

对于“只关心最新状态”的数据,可以使用:

xQueueOverwrite(
    RemoteQueue,
    &remote
);

假设 Queue 长度为 1:

原来:

[ vx = 100 ]

来了新数据:

vx = 180

使用:

xQueueOverwrite()

之后:

[ vx = 180 ]

旧数据:

100

被新数据覆盖。


12.1 xQueueSend() 和 xQueueOverwrite() 的区别

可以简单理解为:

xQueueSend()
= 这条消息要排队保存

xQueueOverwrite()
= 我只关心最新状态

更适合 xQueueOverwrite() 的数据

例如:

  • 当前遥控器状态
  • 当前 IMU 姿态
  • 当前速度
  • 当前角度
  • 当前目标位置

因为这些数据通常是:

状态。

更适合 xQueueSend() 的数据

例如:

  • 发射一次
  • 启动动作
  • 停止动作
  • 切换模式
  • 执行某个命令

因为这些更像:

事件 / 消息。

这些消息通常不能随便覆盖,否则可能把前面的命令丢掉。


十三、xQueueReceive():从 Queue 接收数据

发送以后,自然需要接收。

RemoteData_t remote;

xQueueReceive(
    RemoteQueue,
    &remote,
    0
);

三个参数:

RemoteQueue
→ 从哪个 Queue 接收

&remote
→ 接收到的数据放到哪里

0
→ 没有数据时等待多久

十四、为什么还是 &remote

假设 Queue 中已经有:

vx = 100
vy = 200
wz = 30

执行:

xQueueReceive(
    RemoteQueue,
    &remote,
    0
);

FreeRTOS 会把 Queue 中的数据复制到:

remote

于是:

remote.vx
remote.vy
remote.wz

就可以被控制任务使用。


十五、xQueueReceive() 的返回值

实际工程中建议检查返回值:

if (xQueueReceive(
        RemoteQueue,
        &remote,
        0
    ) == pdPASS)
{
    Chassis_Control(&remote);
}

如果成功:

pdPASS

表示:

收到了数据。

如果失败:

没有数据

所以:

调用 API 不等于 API 一定成功。


十六、xQueueReceive() 的等待方式

和 xQueueSend() 一样,第三个参数决定等待多久。

不等待

xQueueReceive(
    RemoteQueue,
    &remote,
    0
);

表示:

有数据就拿,没有就继续。

这种方式比较适合:

固定周期控制任务

例如:

ControlTask
每1ms运行一次

每次运行时:

看看有没有新的遥控数据
↓
有 → 更新
没有 → 继续使用旧状态

一直等

xQueueReceive(
    RemoteQueue,
    &remote,
    portMAX_DELAY
);

表示:

没数据时当前 Task 就阻塞,直到数据真正到来。

这适合:

有消息才工作的任务

例如:

  • CAN 消息处理任务
  • 上位机命令处理任务
  • 串口命令任务
  • 事件处理任务

十七、一个简单的 STM32 + FreeRTOS 机器人例子

17.1 定义遥控数据

typedef struct
{
    int16_t vx;
    int16_t vy;
    int16_t wz;

} RemoteData_t;

17.2 创建 Queue

QueueHandle_t RemoteQueue;

RemoteQueue = xQueueCreate(
    1,
    sizeof(RemoteData_t)
);

17.3 遥控器 Task

void RemoteTask(void *argument)
{
    RemoteData_t remote;

    while (1)
    {
        Remote_Read(&remote);

        xQueueOverwrite(
            RemoteQueue,
            &remote
        );

        vTaskDelay(
            pdMS_TO_TICKS(10)
        );
    }
}

这个任务:

读取遥控器
    ↓
把最新遥控数据写入 Queue
    ↓
等待10ms
    ↓
再次读取

17.4 控制 Task

void ControlTask(void *argument)
{
    RemoteData_t remote;

    TickType_t lastWakeTime;

    lastWakeTime = xTaskGetTickCount();

    while (1)
    {
        if (xQueueReceive(
                RemoteQueue,
                &remote,
                0
            ) == pdPASS)
        {
            // 收到新的遥控器数据
        }

        Chassis_Control(&remote);

        vTaskDelayUntil(
            &lastWakeTime,
            pdMS_TO_TICKS(1)
        );
    }
}

整个数据流:

遥控器
  ↓
RemoteTask
  ↓
xQueueOverwrite()
  ↓
RemoteQueue
  ↓
xQueueReceive()
  ↓
ControlTask
  ↓
底盘控制
  ↓
PID
  ↓
电机

这就是一个非常基础的机器人控制系统任务通信结构。


十八、本节 API 总结

目前已经学习了:

Task

xTaskCreate()

作用:

创建 Task。


普通延时

vTaskDelay()

作用:

让当前 Task 暂时阻塞。


固定周期

vTaskDelayUntil()

作用:

按照固定的时间节拍执行任务。


Queue

xQueueCreate()

作用:

创建数据队列。

xQueueSend()

作用:

向 Queue 发送数据。

xQueueOverwrite()

作用:

用最新数据覆盖旧数据,适合“状态型”数据。

xQueueReceive()

作用:

从 Queue 获取数据。


十九、目前最重要的心智模型

现阶段不用背很多 API,只需要把下面这张图真正理解:

                  FreeRTOS
                     │
          ┌──────────┴──────────┐
          ↓                     ↓
     RemoteTask            ControlTask
          │                     ↑
          │                     │
          └────── Queue ────────┘
                    │
                    │
               传递数据

然后:

Task
↓
谁负责干活?

vTaskDelay
↓
暂时没事,先阻塞

vTaskDelayUntil
↓
按照固定周期干活

Queue
↓
任务之间传数据

到这里,已经可以构造一个最基础的 STM32 + FreeRTOS 机器人控制框架。


二十、本节应该达到的学习目标

学完这一节,不需要背出所有函数原型。

真正应该做到的是,看到下面的代码能够自己解释:

xTaskCreate(
    RemoteTask,
    "Remote",
    256,
    NULL,
    5,
    NULL
);

vTaskDelay(
    pdMS_TO_TICKS(10)
);

xQueueOverwrite(
    RemoteQueue,
    &remote
);

xQueueReceive(
    RemoteQueue,
    &remote,
    0
);

能够说清楚:

创建了一个 RemoteTask,给它分配一定大小的栈和优先级;任务运行过程中每 10ms 更新一次遥控器数据;数据通过 Queue 传递给控制任务;控制任务再把最新的数据取出来使用。


二十一、后续学习

下一部分将继续学习:

Task
  ↓
Queue
  ↓
Semaphore
  ↓
中断 → Task

重点解决一个机器人开发中特别常见的问题:

CAN 接收中断发生以后,怎样通知一个 FreeRTOS Task 去处理数据?

STM32 + FreeRTOS 课程(1):Task 与基础周期控制、Queue 通信入门

一、前言

在 STM32 裸机开发中,我们经常会把程序写成:

while (1)
{
    Remote_Read();

    IMU_Read();

    PID_Calculate();

    Motor_Output();
}

这种方式在功能比较简单的时候完全可以工作。

但当机器人系统逐渐复杂之后,例如加入:

  • 遥控器
  • IMU
  • CAN 电机
  • PID 控制
  • 上位机通信
  • 云台控制
  • 底盘控制
  • 机械臂控制

所有程序都堆在一个 while(1) 里面就会越来越难维护。

FreeRTOS 的核心作用之一,就是把这些功能拆分成多个任务(Task),并通过 Queue、Semaphore、Mutex 等机制让任务之间进行通信和同步。

本节先学习最基础、也是机器人项目中最常用的一部分:

Task
↓
Delay
↓
固定周期任务
↓
Queue
↓
任务之间的数据通信

二、FreeRTOS 中的 Task

2.1 什么是 Task?

可以简单理解为:

Task 就是一个可以被 FreeRTOS 独立调度的小程序。

例如机器人可以设计成:

RemoteTask       遥控器任务
IMUTask          IMU任务
ControlTask      控制任务
CANTask          CAN任务
DebugTask        调试任务

每个任务负责自己的功能。

例如:

void RemoteTask(void *argument)
{
    while (1)
    {
        Remote_Read();
    }
}

三、xTaskCreate():创建任务

创建 Task 最常用的 API 是:

xTaskCreate();

最常见的形式:

xTaskCreate(
    RemoteTask,
    "Remote",
    256,
    NULL,
    5,
    NULL
);

它的作用就是:

创建一个名为 Remote 的 FreeRTOS 任务,让它执行 RemoteTask()


3.1 第一个参数:任务函数

RemoteTask

表示:

这个 Task 要执行哪个函数。

任务函数通常写成:

void RemoteTask(void *argument)
{
    while (1)
    {
        Remote_Read();
    }
}

FreeRTOS 的任务函数一般采用:

void XXXTask(void *argument)

这种形式。


3.2 第二个参数:任务名字

"Remote"

这是 Task 的名字。

主要用于调试、查看系统中的任务。

例如:

Remote
IMU
Control
CAN
Debug

以后通过调试工具查看 FreeRTOS 任务时,可以很容易知道每个任务负责什么。


3.3 第三个参数:任务栈大小

256

这个参数表示 Task 的 Stack Depth。

需要注意:

这里通常不是直接表示 256 字节,而是表示 256 个 StackType_t

例如:

StackType_t = 4 Bytes

那么:

256 × 4 = 1024 Bytes

即大约 1 KB 栈空间。

栈太小会怎么样?

可能发生 Stack Overflow,导致:

  • 程序异常
  • HardFault
  • 任务跑飞
  • 数据莫名其妙被修改
  • 系统偶发崩溃

栈太大会怎么样?

不会因为“大”本身就出错,但是会浪费 STM32 有限的 RAM。

因此 Task 栈大小不能简单地“越大越好”。

后续可以通过:

uxTaskGetStackHighWaterMark();

观察任务实际栈使用情况,再进行优化。


3.4 第四个参数:传给 Task 的参数

NULL

表示:

当前没有额外的数据需要传给 Task。

例如:

xTaskCreate(
    MotorTask,
    "Motor",
    512,
    &motor1,
    5,
    NULL
);

这里:

&motor1

会传给:

void MotorTask(void *argument)

中的:

argument

所以可以通过 Task 参数,让同一个任务函数服务于不同对象。


3.5 第五个参数:任务优先级

例如:

5

表示 Task 的优先级。

例如:

ControlTask    7
IMUTask        6
RemoteTask     5
DebugTask      2

通常优先级数字越大,优先级越高。

但是需要注意:

高优先级 Task 并不意味着它永远占用 CPU。

只要高优先级任务进入阻塞状态,例如调用:

vTaskDelay();

那么 CPU 就可以运行其他任务。


3.6 第六个参数:Task 句柄

例如:

TaskHandle_t RemoteTaskHandle;

创建:

xTaskCreate(
    RemoteTask,
    "Remote",
    256,
    NULL,
    5,
    &RemoteTaskHandle
);

这个句柄可以理解成:

Task 的“身份证”。

以后可以通过它控制这个 Task,例如:

vTaskSuspend(RemoteTaskHandle);

或者:

vTaskResume(RemoteTaskHandle);

如果当前不需要控制这个 Task,可以直接:

NULL

四、vTaskDelay():让当前 Task 暂时阻塞

最常见写法:

vTaskDelay(pdMS_TO_TICKS(10));

它的作用是:

让当前 Task 暂时进入阻塞状态,并把 CPU 让给其他任务。

例如:

void RemoteTask(void *argument)
{
    while (1)
    {
        Remote_Read();

        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

运行过程可以理解为:

读取遥控器
    ↓
阻塞 10ms
    ↓
继续读取
    ↓
阻塞 10ms
    ↓
继续读取

4.1 为什么需要 Delay?

如果写成:

void RemoteTask(void *argument)
{
    while (1)
    {
        Remote_Read();
    }
}

那么 Task 会不断执行:

Remote_Read()
Remote_Read()
Remote_Read()
Remote_Read()
...

如果它优先级比较高,就可能长时间占用 CPU。

而:

vTaskDelay();

会让当前 Task 主动进入阻塞状态,让其他 Task 获得运行机会。


4.2 pdMS_TO_TICKS() 是什么?

例如:

vTaskDelay(pdMS_TO_TICKS(10));

表示:

延时 10ms。

vTaskDelay() 本身使用的是 FreeRTOS Tick,因此使用:

pdMS_TO_TICKS()

可以把毫秒转换成对应的 Tick。

相比直接写:

vTaskDelay(10);

更容易阅读,也更不容易因为 Tick 配置不同而产生误解。


五、vTaskDelayUntil():固定周期控制

普通的:

vTaskDelay();

更像:

执行
↓
等待一段时间
↓
执行
↓
等待一段时间

如果任务本身需要一定执行时间,那么实际的任务周期还会受到执行时间影响。

例如:

while (1)
{
    PID_Calculate();

    vTaskDelay(pdMS_TO_TICKS(1));
}

如果:

PID_Calculate() 执行时间 = 0.3ms

那么一次循环可能接近:

0.3ms + 1ms

所以不一定严格是 1ms 周期。


5.1 固定周期任务应该使用 vTaskDelayUntil()

典型写法:

void ControlTask(void *argument)
{
    TickType_t lastWakeTime;

    lastWakeTime = xTaskGetTickCount();

    while (1)
    {
        PID_Calculate();

        vTaskDelayUntil(
            &lastWakeTime,
            pdMS_TO_TICKS(1)
        );
    }
}

可以理解为:

让这个 Task 按照固定的时间节拍运行。

例如目标周期为 1ms:

0ms      PID
1ms      PID
2ms      PID
3ms      PID
4ms      PID

5.2 vTaskDelay() 和 vTaskDelayUntil() 的区别

API主要用途
vTaskDelay()普通的暂时阻塞
vTaskDelayUntil()固定周期任务

可以简单记忆:

vTaskDelay()
= 做完事情,再等一段时间

vTaskDelayUntil()
= 按照固定节拍运行

机器人中的:

  • PID 控制
  • 姿态控制
  • 速度环
  • 位置环

通常更适合使用固定周期设计。


六、Queue:任务之间的数据通道

当多个 Task 出现以后,一个很现实的问题就是:

一个任务得到的数据,怎么交给另一个任务?

例如:

RemoteTask
    ↓
读取遥控器

ControlTask
    ↓
读取遥控数据并进行控制

可以使用 FreeRTOS Queue。

可以把 Queue 想象成:

一个任务之间传递数据的“盒子”。


七、xQueueCreate():创建 Queue

例如:

QueueHandle_t RemoteQueue;

RemoteQueue = xQueueCreate(
    1,
    sizeof(RemoteData_t)
);

xQueueCreate() 有两个核心参数。


7.1 第一个参数:Queue 长度

1

表示:

这个 Queue 最多存放多少个元素。

这里表示:

最多存 1 份 RemoteData_t

7.2 第二个参数:每个元素的大小

sizeof(RemoteData_t)

例如:

typedef struct
{
    int16_t vx;
    int16_t vy;
    int16_t wz;

} RemoteData_t;

那么:

sizeof(RemoteData_t)

就是一份遥控器数据需要的内存大小。

所以:

xQueueCreate(
    1,
    sizeof(RemoteData_t)
);

可以翻译为:

创建一个最多保存 1 份遥控器数据的 Queue。


八、为什么很多机器人状态数据只保存最新值?

遥控器数据通常属于“状态”。

例如:

10ms前:vx = 100
现在:  vx = 200

控制系统通常更关心:

当前 vx = 200

而不是把过去的数据排队慢慢处理:

100 → 120 → 150 → 180 → 200

所以很多状态类数据会采用:

Queue 长度 = 1
+
xQueueOverwrite()

九、为什么不用全局变量?

最简单的 STM32 程序中,当然可以使用全局变量:

RemoteData_t g_remote;

然后:

RemoteTask
    ↓
修改 g_remote

ControlTask
    ↓
读取 g_remote

这种方法简单,而且很多情况下完全可用。

但在大型机器人系统中,如果所有模块都依赖大量全局变量:

g_remote
g_imu
g_motor
g_vision
g_arm
g_pc

就容易出现:

谁负责修改?

谁负责读取?

什么时间修改?

会不会同时读写?

数据到底从哪里流向哪里?

Queue 的一个重要价值就是明确地定义:

数据生产者
    ↓
Queue
    ↓
数据消费者

这样任务之间的数据流更加清晰。

需要注意:

并不是所有数据都必须使用 Queue。

简单的系统状态、只读参数、某些共享状态仍然可以使用全局变量或共享结构体。应该根据数据的性质选择合适的方法。


十、xQueueSend():向 Queue 发送数据

基本写法:

RemoteData_t remote;

xQueueSend(
    RemoteQueue,
    &remote,
    0
);

三个核心参数:

RemoteQueue
→ 往哪个 Queue 发送

&remote
→ 发送什么数据

0
→ Queue 满了以后等待多久

10.1 第一个参数

RemoteQueue

表示:

要把数据发送到哪个 Queue。


10.2 第二个参数

&remote

表示:

要发送的数据所在的地址。

FreeRTOS 会根据 Queue 创建时指定的数据大小,把数据复制到 Queue 中。

常见形式:

xQueueSend(
    Queue,
    &data,
    ...
);

10.3 第三个参数:阻塞时间

例如:

xQueueSend(
    RemoteQueue,
    &remote,
    0
);

表示:

Queue 满了就立即返回,不等待。

而:

xQueueSend(
    RemoteQueue,
    &remote,
    pdMS_TO_TICKS(10)
);

表示:

Queue 满了以后,最多等待 10ms。

还可以:

portMAX_DELAY

表示:

可以一直阻塞等待,直到有空间。


十一、为什么实时控制中不应该随便长时间等待?

假设遥控器任务:

10ms 更新一次

但是你写:

xQueueSend(
    RemoteQueue,
    &remote,
    pdMS_TO_TICKS(100)
);

如果 Queue 满了:

发送
 ↓
Queue满
 ↓
等待
 ↓
最多100ms

那么遥控数据更新可能被拖慢。

实时控制系统通常更关注:

及时性。

因此要根据具体数据的性质决定到底要不要等待。


十二、xQueueOverwrite():覆盖旧数据

对于“只关心最新状态”的数据,可以使用:

xQueueOverwrite(
    RemoteQueue,
    &remote
);

假设 Queue 长度为 1:

原来:

[ vx = 100 ]

来了新数据:

vx = 180

使用:

xQueueOverwrite()

之后:

[ vx = 180 ]

旧数据:

100

被新数据覆盖。


12.1 xQueueSend() 和 xQueueOverwrite() 的区别

可以简单理解为:

xQueueSend()
= 这条消息要排队保存

xQueueOverwrite()
= 我只关心最新状态

更适合 xQueueOverwrite() 的数据

例如:

  • 当前遥控器状态
  • 当前 IMU 姿态
  • 当前速度
  • 当前角度
  • 当前目标位置

因为这些数据通常是:

状态。

更适合 xQueueSend() 的数据

例如:

  • 发射一次
  • 启动动作
  • 停止动作
  • 切换模式
  • 执行某个命令

因为这些更像:

事件 / 消息。

这些消息通常不能随便覆盖,否则可能把前面的命令丢掉。


十三、xQueueReceive():从 Queue 接收数据

发送以后,自然需要接收。

RemoteData_t remote;

xQueueReceive(
    RemoteQueue,
    &remote,
    0
);

三个参数:

RemoteQueue
→ 从哪个 Queue 接收

&remote
→ 接收到的数据放到哪里

0
→ 没有数据时等待多久

十四、为什么还是 &remote

假设 Queue 中已经有:

vx = 100
vy = 200
wz = 30

执行:

xQueueReceive(
    RemoteQueue,
    &remote,
    0
);

FreeRTOS 会把 Queue 中的数据复制到:

remote

于是:

remote.vx
remote.vy
remote.wz

就可以被控制任务使用。


十五、xQueueReceive() 的返回值

实际工程中建议检查返回值:

if (xQueueReceive(
        RemoteQueue,
        &remote,
        0
    ) == pdPASS)
{
    Chassis_Control(&remote);
}

如果成功:

pdPASS

表示:

收到了数据。

如果失败:

没有数据

所以:

调用 API 不等于 API 一定成功。


十六、xQueueReceive() 的等待方式

和 xQueueSend() 一样,第三个参数决定等待多久。

不等待

xQueueReceive(
    RemoteQueue,
    &remote,
    0
);

表示:

有数据就拿,没有就继续。

这种方式比较适合:

固定周期控制任务

例如:

ControlTask
每1ms运行一次

每次运行时:

看看有没有新的遥控数据
↓
有 → 更新
没有 → 继续使用旧状态

一直等

xQueueReceive(
    RemoteQueue,
    &remote,
    portMAX_DELAY
);

表示:

没数据时当前 Task 就阻塞,直到数据真正到来。

这适合:

有消息才工作的任务

例如:

  • CAN 消息处理任务
  • 上位机命令处理任务
  • 串口命令任务
  • 事件处理任务

十七、一个简单的 STM32 + FreeRTOS 机器人例子

17.1 定义遥控数据

typedef struct
{
    int16_t vx;
    int16_t vy;
    int16_t wz;

} RemoteData_t;

17.2 创建 Queue

QueueHandle_t RemoteQueue;

RemoteQueue = xQueueCreate(
    1,
    sizeof(RemoteData_t)
);

17.3 遥控器 Task

void RemoteTask(void *argument)
{
    RemoteData_t remote;

    while (1)
    {
        Remote_Read(&remote);

        xQueueOverwrite(
            RemoteQueue,
            &remote
        );

        vTaskDelay(
            pdMS_TO_TICKS(10)
        );
    }
}

这个任务:

读取遥控器
    ↓
把最新遥控数据写入 Queue
    ↓
等待10ms
    ↓
再次读取

17.4 控制 Task

void ControlTask(void *argument)
{
    RemoteData_t remote;

    TickType_t lastWakeTime;

    lastWakeTime = xTaskGetTickCount();

    while (1)
    {
        if (xQueueReceive(
                RemoteQueue,
                &remote,
                0
            ) == pdPASS)
        {
            // 收到新的遥控器数据
        }

        Chassis_Control(&remote);

        vTaskDelayUntil(
            &lastWakeTime,
            pdMS_TO_TICKS(1)
        );
    }
}

整个数据流:

遥控器
  ↓
RemoteTask
  ↓
xQueueOverwrite()
  ↓
RemoteQueue
  ↓
xQueueReceive()
  ↓
ControlTask
  ↓
底盘控制
  ↓
PID
  ↓
电机

这就是一个非常基础的机器人控制系统任务通信结构。


十八、本节 API 总结

目前已经学习了:

Task

xTaskCreate()

作用:

创建 Task。


普通延时

vTaskDelay()

作用:

让当前 Task 暂时阻塞。


固定周期

vTaskDelayUntil()

作用:

按照固定的时间节拍执行任务。


Queue

xQueueCreate()

作用:

创建数据队列。

xQueueSend()

作用:

向 Queue 发送数据。

xQueueOverwrite()

作用:

用最新数据覆盖旧数据,适合“状态型”数据。

xQueueReceive()

作用:

从 Queue 获取数据。


十九、目前最重要的心智模型

现阶段不用背很多 API,只需要把下面这张图真正理解:

                  FreeRTOS
                     │
          ┌──────────┴──────────┐
          ↓                     ↓
     RemoteTask            ControlTask
          │                     ↑
          │                     │
          └────── Queue ────────┘
                    │
                    │
               传递数据

然后:

Task
↓
谁负责干活?

vTaskDelay
↓
暂时没事,先阻塞

vTaskDelayUntil
↓
按照固定周期干活

Queue
↓
任务之间传数据

到这里,已经可以构造一个最基础的 STM32 + FreeRTOS 机器人控制框架。


二十、本节应该达到的学习目标

学完这一节,不需要背出所有函数原型。

真正应该做到的是,看到下面的代码能够自己解释:

xTaskCreate(
    RemoteTask,
    "Remote",
    256,
    NULL,
    5,
    NULL
);

vTaskDelay(
    pdMS_TO_TICKS(10)
);

xQueueOverwrite(
    RemoteQueue,
    &remote
);

xQueueReceive(
    RemoteQueue,
    &remote,
    0
);

能够说清楚:

创建了一个 RemoteTask,给它分配一定大小的栈和优先级;任务运行过程中每 10ms 更新一次遥控器数据;数据通过 Queue 传递给控制任务;控制任务再把最新的数据取出来使用。


二十一、后续学习

下一部分将继续学习:

Task
  ↓
Queue
  ↓
Semaphore
  ↓
中断 → Task

重点解决一个机器人开发中特别常见的问题:

CAN 接收中断发生以后,怎样通知一个 FreeRTOS Task 去处理数据?

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐