第三章:飞控系统软件架构:实时操作系统(FreeRTOS)基础、任务划分与优先级设计、任务间通信机制
好,我们进入第三章。这一章,咱们聊聊飞控的“灵魂”——软件架构。
飞控硬件是骨架,那软件就是血肉和神经。没有一套好的软件架构,再牛的传感器、再快的处理器,也只是一堆废铁。我个人习惯,在设计任何嵌入式系统之前,先把软件架构想清楚。飞控尤其如此,它是个典型的“硬实时”系统——你想想看,如果姿态解算晚了几毫秒,或者控制输出卡顿了一下,飞机可能就翻跟头了。
3.1 为什么飞控离不开实时操作系统(RTOS)?
很多刚入行的朋友问我:“飞控代码能不能用裸机(超级循环)写?”
我的回答是:能,但你会非常痛苦。
裸机程序就像只有一个厨师的小饭馆,所有菜(任务)都得排队做。你炒完菜(传感器读取),才能去切菜(姿态解算),然后才能去装盘(输出PWM)。如果这时候突然来了个紧急订单(遥控器信号丢失),你得等当前这盘菜做完才能处理。这在飞控里是致命的。
而RTOS,就像一个大厨房,有好几个厨师(任务)同时干活。有人专门洗菜(读取传感器),有人专门炒菜(姿态解算),有人专门装盘(输出控制)。而且,一旦有紧急情况(比如掉电检测),可以立刻中断当前工作去处理。
说白了,RTOS的核心价值就两点:实时性和并发性。
核心概念:实时操作系统(RTOS)能保证在确定的时间内响应外部事件。飞控里,这个“确定的时间”通常是毫秒级甚至微秒级。
FreeRTOS是飞控领域最常用的RTOS之一。为什么?因为它轻量、开源、移植性好。我在项目中遇到过用uC/OS的,但后来都转向FreeRTOS了,原因很简单——生态好,社区活跃,遇到问题容易找到答案。
3.2 FreeRTOS基础:任务、调度与状态
我们先快速过一下FreeRTOS的几个核心概念。这些是地基,地基不稳,楼盖得再高也得塌。
3.2.1 任务(Task)
任务就是一段独立的、无限循环的函数。每个任务都有自己的栈空间和优先级。在飞控里,每个功能模块通常就是一个任务。
创建任务的API长这样:
// 创建一个任务
xTaskCreate(
vTaskFunction, // 任务函数指针
"Task Name", // 任务名称(调试用)
STACK_SIZE, // 栈大小(单位:字)
NULL, // 任务参数
PRIORITY, // 任务优先级
NULL // 任务句柄
);
嗯,这里要注意:栈大小别给少了。我曾经有个同事,给姿态解算任务分配了256字节的栈,结果飞着飞着就莫名其妙重启了。查了两天才发现是栈溢出。飞控里,我建议每个任务至少给512字节以上,复杂的任务(比如SD卡日志记录)给1024甚至2048。
3.2.2 任务状态与调度
FreeRTOS的任务有四种状态:运行态、就绪态、阻塞态、挂起态。
调度器负责决定哪个任务该运行。FreeRTOS用的是抢占式优先级调度——说白了,就是优先级高的任务随时可以打断优先级低的任务。
| 状态 | 说明 | 飞控中的例子 |
|---|---|---|
| 运行态 | 当前正在使用CPU | 正在执行姿态解算 |
| 就绪态 | 可以运行,但CPU被更高优先级的任务占用了 | 等待运行的传感器读取任务 |
| 阻塞态 | 等待某个事件(延时、信号量、队列等) | 等待GPS数据更新的任务 |
| 挂起态 | 被vTaskSuspend()暂停 | 调试时手动暂停某个任务 |
我的经验:飞控里,大部分时间任务都处于阻塞态。比如姿态解算任务,以250Hz运行,那它在4ms的周期里,大部分时间都在等待定时器。这样CPU才能有空处理其他事情。如果所有任务都在疯狂占用CPU,那你的飞控离炸机不远了。
3.3 任务划分与优先级设计——飞控架构的核心
这部分是我最想跟你聊的。任务怎么分?优先级怎么定?这直接决定了飞控的稳定性和响应速度。
3.3.1 任务划分原则
我个人习惯,把飞控任务分成三类:
- 硬实时任务:错过截止时间就会导致灾难。比如姿态控制输出(通常1kHz)、传感器数据读取(IMU通常1kHz)。
- 软实时任务:错过截止时间会影响性能,但不会立刻炸机。比如姿态解算(250-500Hz)、位置控制(50-100Hz)。
- 非实时任务:晚一点没关系。比如日志记录、参数存储、遥测数据发送。
一个典型的飞控任务划分如下:
// 伪代码:飞控任务划分
xTaskCreate(vTaskIMURead, "IMU", 512, NULL, 5, NULL); // 最高优先级
xTaskCreate(vTaskControl, "CTRL", 512, NULL, 4, NULL); // 控制输出
xTaskCreate(vTaskAttitude, "ATT", 1024, NULL, 3, NULL); // 姿态解算
xTaskCreate(vTaskGPS, "GPS", 512, NULL, 2, NULL); // GPS处理
xTaskCreate(vTaskTelemetry, "TEL", 512, NULL, 1, NULL); // 遥测
xTaskCreate(vTaskLog, "LOG", 1024, NULL, 0, NULL); // 日志(最低优先级)
3.3.2 优先级设计的“黄金法则”
优先级设计没有标准答案,但有几条铁律:
- 控制周期越短,优先级越高。IMU读取1kHz,优先级就该比10Hz的GPS高。
- 对安全性影响越大,优先级越高。失控保护(Failsafe)任务的优先级应该高于普通任务。
- 避免优先级反转。这个我吃过亏。曾经有个低优先级的日志任务占用了某个互斥锁,导致高优先级的控制任务被阻塞。解决办法是用优先级继承或干脆不用互斥锁,改用队列。
避坑指南:我曾经在一个项目中,把姿态解算任务的优先级设得比IMU读取还高。结果呢?IMU数据还没更新,姿态解算就拿着旧数据算了一遍,导致控制输出抖动。记住:数据生产者(IMU读取)的优先级,应该高于数据消费者(姿态解算)。
3.4 任务间通信机制——让任务“好好说话”
任务之间不是孤立的。IMU读取任务要把数据传给姿态解算任务,姿态解算任务要把结果传给控制任务。怎么传?这就涉及到任务间通信(IPC)。
3.4.1 队列(Queue)——最常用的通信方式
队列是FreeRTOS中最核心的IPC机制。它本质上是FIFO(先进先出)的缓冲区,一个任务往里写,另一个任务往外读。
// 创建队列(存放IMU数据,每个数据20字节,队列深度10)
QueueHandle_t xIMUQueue = xQueueCreate(10, sizeof(IMU_Data_t));
// 发送任务(IMU读取)
void vTaskIMURead(void *pvParameters) {
IMU_Data_t imuData;
while(1) {
// 读取传感器...
xQueueSend(xIMUQueue, &imuData, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(1)); // 1kHz
}
}
// 接收任务(姿态解算)
void vTaskAttitude(void *pvParameters) {
IMU_Data_t imuData;
while(1) {
if(xQueueReceive(xIMUQueue, &imuData, portMAX_DELAY) == pdPASS) {
// 进行姿态解算...
}
}
}
队列的好处是天然线程安全。你想想看,如果两个任务同时访问一个全局变量,不加锁的话数据就乱了。队列内部用临界区保护了,你只管用就行。
3.4.2 信号量(Semaphore)——用于同步和互斥
信号量分两种:二值信号量和计数信号量。
- 二值信号量:就像一把钥匙。任务A拿到钥匙才能干活,干完活把钥匙还回去。常用于任务同步。比如,DMA传输完成后,在中断中释放信号量,唤醒等待的任务。
- 计数信号量:就像一叠票。可以记录事件发生的次数。比如,串口接收了10个字节,释放10次信号量,接收任务就处理10次。
重要提醒:中断服务函数(ISR)中,只能使用带“FromISR”后缀的API,比如xSemaphoreGiveFromISR()。千万别在中断里调用xSemaphoreGive(),会死锁的。我刚开始写飞控时就犯过这个错,一进中断就死机。
3.4.3 任务通知(Task Notification)——轻量级通信
任务通知是FreeRTOS特有的机制,比信号量更快、更省内存。每个任务都有一个32位的通知值,可以直接用这个值来传递数据或作为信号量。
// 发送通知(在中断中)
void vISR_Handler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
vTaskNotifyGiveFromISR(xTaskToNotify, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 接收通知(在任务中)
void vTaskReceiver(void *pvParameters) {
while(1) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // 等待通知
// 处理事件...
}
}
任务通知比信号量快约45%(官方数据),但有个限制:只能一对一通信。如果你需要一对多广播,还是得用队列或事件组。
3.5 飞控任务架构图
下面这张图,是我自己画的一个典型飞控任务架构。你可以看到数据是怎么流动的,任务之间怎么通信。
这张图里,数据从传感器任务出发,经过队列传递给处理任务,再经过队列传递给控制任务,最后输出到执行器。每个任务各司其职,互不干扰。这就是RTOS的魅力。
3.6 总结与避坑清单
好,这一章的内容差不多了。我最后给你列个清单,都是我在实际项目中踩过的坑:
- 栈大小:别省。飞控里,栈溢出是最难查的bug之一。用uxTaskGetStackHighWaterMark()监控栈使用情况。
- 优先级反转:尽量避免在飞控中使用互斥锁。如果非用不可,记得开启优先级继承(configUSE_MUTEXES和configUSE_PRIORITY_INHERITANCE)。
- 中断服务函数:越短越好。中断里只做标记或释放信号量,具体处理放到任务里做。
- 任务周期:别用vTaskDelay()做精确定时。用定时器或vTaskDelayUntil(),后者能补偿任务执行时间。
- 看门狗:飞控一定要开硬件看门狗。我曾经有一次,任务死锁了,飞机直接自由落体。从那以后,我每个项目都加看门狗。
最后说一句:FreeRTOS的配置项很多,但飞控里常用的就那几个。别被复杂的配置吓到。先把任务跑起来,再慢慢优化。记住,能跑起来的代码,比完美的代码更有价值。
公众号:蓝海资料掘金营,微信deep3321