第三章:飞控系统软件架构:实时操作系统(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 飞控任务架构图

下面这张图,是我自己画的一个典型飞控任务架构。你可以看到数据是怎么流动的,任务之间怎么通信。

飞控系统任务架构与数据流 IMU读取任务 优先级: 5 | 1kHz GPS读取任务 优先级: 3 | 10Hz 遥控器接收任务 优先级: 4 | 50Hz 队列 队列 队列 姿态解算任务 优先级: 3 | 250Hz 位置控制任务 优先级: 2 | 50Hz 控制输出任务 优先级: 4 | 1kHz PWM输出/电机 图例 传感器读取任务 处理/解算任务 控制输出任务 队列通信

这张图里,数据从传感器任务出发,经过队列传递给处理任务,再经过队列传递给控制任务,最后输出到执行器。每个任务各司其职,互不干扰。这就是RTOS的魅力。

3.6 总结与避坑清单

好,这一章的内容差不多了。我最后给你列个清单,都是我在实际项目中踩过的坑:

  • 栈大小:别省。飞控里,栈溢出是最难查的bug之一。用uxTaskGetStackHighWaterMark()监控栈使用情况。
  • 优先级反转:尽量避免在飞控中使用互斥锁。如果非用不可,记得开启优先级继承(configUSE_MUTEXES和configUSE_PRIORITY_INHERITANCE)。
  • 中断服务函数:越短越好。中断里只做标记或释放信号量,具体处理放到任务里做。
  • 任务周期:别用vTaskDelay()做精确定时。用定时器或vTaskDelayUntil(),后者能补偿任务执行时间。
  • 看门狗:飞控一定要开硬件看门狗。我曾经有一次,任务死锁了,飞机直接自由落体。从那以后,我每个项目都加看门狗。

最后说一句:FreeRTOS的配置项很多,但飞控里常用的就那几个。别被复杂的配置吓到。先把任务跑起来,再慢慢优化。记住,能跑起来的代码,比完美的代码更有价值。


公众号:蓝海资料掘金营,微信deep3321