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


所有评论(0)