第4章:实时操作系统(RTOS)入门:FreeRTOS在飞控中的角色,任务调度与优先级,为什么裸机不够用了?
说实话,我见过不少做飞控的初学者,上来就问我:「老师,飞控代码这么复杂,用裸机大循环不行吗?」
我的回答很直接:不行,绝对不行。
你想想看,一架四轴飞行器在空中,每秒要处理传感器数据、运行姿态解算、输出控制信号、还要响应遥控器指令。如果这些事都挤在一个while(1)大循环里,任何一个任务卡住几毫秒,飞机可能就翻跟头了。我早期做第一版飞控原型时,就吃过这个亏——裸机跑着跑着,电机突然停转,飞机直接砸地。后来一查,是某个传感器读取函数占用了太长时间。
4.1 为什么裸机不够用了?
裸机编程,说白了就是一个无限循环,加上中断服务函数。结构大概是这样:
void main(void)
{
while(1)
{
read_sensor(); // 读取传感器
attitude_estimate(); // 姿态解算
control_output(); // 输出控制
communication(); // 通信处理
}
}
这种结构有什么问题?我总结了几点:
- 任务互相阻塞:如果read_sensor()因为I2C总线忙而卡了5ms,后面所有任务都得等着。飞控里最怕的就是这种不确定性。
- 实时性无法保证:姿态解算要求1ms内完成,但通信任务可能占用2ms。你没法精确控制每个任务的执行时间。
- 代码耦合严重:所有功能揉在一起,想加个新功能?改一处可能影响全局。我见过一个裸机飞控项目,后期维护成本高得吓人。
- 中断滥用:有人把所有事情都塞进中断里。嗯,这更危险——中断里做浮点运算、调用printf,分分钟堆栈溢出。
我曾经接手过一个裸机飞控项目,代码里用了5个定时器中断,每个中断里都做大量计算。结果飞机在高机动飞行时,中断嵌套导致堆栈溢出,直接失控炸机。从那以后,我坚决不在飞控里用裸机大循环。
4.2 FreeRTOS在飞控中的角色
FreeRTOS是什么?它就是一个轻量级的实时操作系统内核。在飞控里,它的角色可以概括为:任务调度器 + 资源管理者。
我个人习惯把飞控任务拆成几个独立线程:
- 传感器采集任务:以1kHz频率读取IMU数据
- 姿态解算任务:以500Hz频率运行互补滤波或卡尔曼滤波
- 控制输出任务:以1kHz频率输出PWM信号
- 通信任务:处理遥控器SBUS信号和遥测数据
- 日志任务:记录飞行数据到SD卡
每个任务都有自己的栈空间,互不干扰。FreeRTOS负责决定「现在该运行哪个任务」。这就好比一个交响乐团的指挥,让每个乐器在正确的时间演奏。
在飞控中,FreeRTOS不是「锦上添花」,而是「雪中送炭」。它把复杂的时序管理从你的大脑中解放出来,让你专注于算法本身。
4.3 任务调度与优先级
FreeRTOS支持两种调度方式:抢占式调度和协作式调度。飞控里几乎只用抢占式。
抢占式调度的核心逻辑是:高优先级任务随时可以打断低优先级任务。举个例子:
// 任务优先级定义
#define PRIO_SENSOR 5 // 最高
#define PRIO_ATTITUDE 4
#define PRIO_CONTROL 3
#define PRIO_COMM 2
#define PRIO_LOG 1 // 最低
void vTaskSensor(void *pvParameters)
{
while(1)
{
read_imu();
// 读取完成后,主动让出CPU给姿态解算任务
vTaskDelay(pdMS_TO_TICKS(1));
}
}
void vTaskAttitude(void *pvParameters)
{
while(1)
{
estimate_attitude();
vTaskDelay(pdMS_TO_TICKS(2));
}
}
这里要注意:优先级不是越高越好。我见过有人把所有任务都设成最高优先级,结果系统一直在切换任务,CPU全浪费在上下文切换上了。
| 优先级 | 典型任务 | 周期要求 | 说明 |
|---|---|---|---|
| 最高 | 传感器读取 | 1ms | 必须准时,否则姿态解算会漂 |
| 高 | 姿态解算 | 2ms | 计算量大,但可容忍少量抖动 |
| 中 | 控制输出 | 1ms | 与传感器同步,优先级可略低 |
| 低 | 通信/日志 | 10-100ms | 偶尔延迟没关系 |
我建议在飞控中保留一个空闲任务,用来统计CPU使用率。如果空闲任务运行时间少于20%,说明CPU快满了,需要优化。我曾经在一个项目中,通过空闲任务发现CPU占用率高达95%,后来发现是某个任务里用了vTaskDelay(0)——这会导致任务一直让出CPU,造成频繁切换。
4.4 任务间通信:队列与信号量
任务之间怎么传数据?裸机里用全局变量,但在RTOS里,千万别直接用全局变量。为什么?因为任务切换可能发生在任何时刻,你读一半数据,另一个任务把数据改了,这就出问题了。
FreeRTOS提供了几种机制:
- 队列(Queue):用于传递数据块,比如把IMU原始数据从传感器任务传给姿态解算任务
- 信号量(Semaphore):用于同步,比如通知控制任务「传感器数据已就绪」
- 事件组(Event Group):用于多条件触发,比如「GPS定位完成且遥控器信号正常」才允许解锁
举个例子,传感器任务和姿态解算任务之间的典型通信:
// 定义队列句柄
QueueHandle_t xImuQueue;
// 传感器任务:发送数据
void vTaskSensor(void *pvParameters)
{
ImuData_t xData;
while(1)
{
read_imu(&xData);
// 发送到队列,如果队列满则等待10ms
xQueueSend(xImuQueue, &xData, pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(1));
}
}
// 姿态解算任务:接收数据
void vTaskAttitude(void *pvParameters)
{
ImuData_t xData;
while(1)
{
// 从队列接收,如果队列空则阻塞等待
if(xQueueReceive(xImuQueue, &xData, portMAX_DELAY) == pdPASS)
{
estimate_attitude(&xData);
}
}
}
队列长度要合理设置。太短会导致数据丢失,太长会浪费内存。我一般根据任务频率来算:如果传感器任务1ms发一次,姿态解算2ms收一次,队列长度设3就够用了。设成100纯粹是浪费RAM。
4.5 飞控中的典型任务划分
最后,我分享一下自己常用的飞控任务架构。这是一个经过多次迭代的方案,你可以参考:
- 高优先级任务(1ms周期):读取IMU、磁力计、气压计。这些传感器数据是飞控的「眼睛」,必须最新。
- 中优先级任务(2-5ms周期):姿态解算、控制律计算。这是飞控的「大脑」,计算量大但可以容忍少量延迟。
- 低优先级任务(10-100ms周期):遥控器信号解析、遥测数据发送、SD卡写入。这些任务偶尔被抢占也没关系。
- 后台任务:系统监控、故障检测、LED指示。优先级最低,有空就跑一下。
嗯,这里要注意:任务栈大小要合理分配。我见过有人给每个任务分配512字节,结果姿态解算任务里用了递归函数,栈溢出导致系统崩溃。我一般给高优先级任务分配256-512字节,给计算密集型任务分配1024-2048字节。
裸机编程在飞控领域已经不够用了。FreeRTOS提供了任务调度、优先级管理、任务间通信等机制,让飞控代码更清晰、更可靠、更易维护。从裸机到RTOS,不是技术炫技,而是工程实践的必然选择。下一章,我会带你深入FreeRTOS的源码,看看任务切换到底是怎么实现的。