4、实时操作系统基础:FreeRTOS任务创建与调度、任务优先级与堆栈配置、临界区与同步机制
各位同学,欢迎来到第四章。这一章我们聊聊实时操作系统,具体说就是 FreeRTOS。
说实话,我刚入行那会儿,对 RTOS 是又爱又怕。爱的是它能帮我管理多个任务,怕的是稍不留神就出 bug。后来做飞控项目多了,才慢慢摸清门道。今天我把这些经验掰开揉碎讲给你听。
4.1 为什么飞控离不开 RTOS?
飞控系统要同时干好几件事:读传感器、解算姿态、控制电机、处理遥控器信号……
你想想看,如果用裸机轮询,一个循环跑下来,传感器数据可能都过期了。我见过一个新手用裸机做飞控,姿态解算还没算完,电机已经转飞了。
RTOS 的核心价值就两个字:实时。它保证高优先级的任务(比如电机控制)能在规定时间内得到响应。说白了,就是给每个任务排好队,谁急谁先上。
核心概念:实时操作系统不是跑得快,而是响应时间可预测。飞控里,电机控制必须在 1ms 内响应,晚一点都不行。
4.2 任务创建与调度——让代码“分时复用”
任务在 FreeRTOS 里就是一个无限循环的函数。我习惯把每个功能模块封装成一个独立任务,比如传感器采集任务、姿态解算任务、控制输出任务。
创建任务的 API 很简单:
// 任务句柄
TaskHandle_t xSensorTaskHandle = NULL;
// 任务函数
void vSensorTask(void *pvParameters)
{
while(1)
{
// 读取传感器数据
ReadIMU();
// 让出 CPU,给其他任务运行机会
vTaskDelay(pdMS_TO_TICKS(5)); // 5ms 周期
}
}
// 创建任务
xTaskCreate(
vSensorTask, // 任务函数
"Sensor Task", // 任务名称(调试用)
256, // 堆栈大小(单位:字)
NULL, // 参数
2, // 优先级
&xSensorTaskHandle // 任务句柄
);
这里有个坑:vTaskDelay 不是让任务“睡死”,而是让任务进入阻塞态,把 CPU 让给其他就绪态任务。调度器会根据优先级决定谁先运行。
FreeRTOS 的调度策略是抢占式优先级调度。什么意思?高优先级任务一旦就绪,立刻打断低优先级任务。我曾在项目中遇到过一个问题:一个低优先级的日志打印任务,因为高优先级任务一直占着 CPU,死活跑不出来。后来加了适当的延时才解决。
个人经验:任务函数里一定要有阻塞点(vTaskDelay、等待队列、等待信号量),否则低优先级任务永远没机会运行。这叫“任务饥饿”,是新手最容易犯的错误。
4.3 任务优先级与堆栈配置——别让系统“爆栈”
优先级决定了任务的“话语权”。飞控里,我通常这样分配:
| 优先级 | 任务类型 | 说明 |
|---|---|---|
| 最高(5) | 电机控制 | 必须实时响应,1ms 周期 |
| 高(4) | 姿态解算 | 需要及时更新,5ms 周期 |
| 中(3) | 传感器采集 | 周期性读取,10ms 周期 |
| 低(2) | 遥控器处理 | 可以容忍一定延迟 |
| 最低(1) | 日志输出 | 后台任务,有空才跑 |
堆栈配置是个技术活。堆栈太小,任务会溢出;堆栈太大,浪费内存。我一般这样估算:
- 任务函数里的局部变量总大小
- 函数调用嵌套深度(每层约 20-40 字节)
- 中断嵌套的上下文保存(约 100 字节)
- 再加 20% 的余量
避坑指南:我曾经在一个项目中,堆栈只配了 128 字,结果任务跑着跑着就死机了。查了两天才发现是堆栈溢出,把相邻的任务数据给踩了。后来我养成了习惯:每个任务至少配 256 字,调试阶段用 uxTaskGetStackHighWaterMark() 检查实际使用量。
4.4 临界区与同步机制——保护共享资源
多个任务同时访问同一个变量,会出大问题。比如姿态解算任务在更新姿态角,控制任务突然读了一半,数据就乱了。
临界区就是解决这个问题的。它保证一段代码在执行时不会被其他任务或中断打断。
FreeRTOS 提供了几种同步机制:
4.4.1 关中断(最粗暴的方式)
taskENTER_CRITICAL();
// 访问共享资源
g_attitude.roll = new_roll;
g_attitude.pitch = new_pitch;
taskEXIT_CRITICAL();
这种方式简单,但关中断时间不能太长,否则会影响系统实时性。我一般只在操作几个变量时用,超过 10 条语句就换别的机制。
4.4.2 信号量(更优雅的方式)
SemaphoreHandle_t xSemaphore = NULL;
// 创建二值信号量
xSemaphore = xSemaphoreCreateBinary();
// 任务A:写数据
void vWriterTask(void *pvParameters)
{
while(1)
{
if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE)
{
// 写共享数据
WriteData();
xSemaphoreGive(xSemaphore);
}
}
}
// 任务B:读数据
void vReaderTask(void *pvParameters)
{
while(1)
{
if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE)
{
// 读共享数据
ReadData();
xSemaphoreGive(xSemaphore);
}
}
}
信号量就像一把钥匙。谁拿到钥匙谁就能进房间,出来时把钥匙放下。这样就不会两个人同时挤在房间里了。
4.4.3 队列(任务间通信的首选)
我更喜欢用队列来传递数据。比如传感器任务把数据打包成结构体,通过队列发给姿态解算任务:
// 定义数据结构
typedef struct
{
float accel_x;
float accel_y;
float accel_z;
float gyro_x;
float gyro_y;
float gyro_z;
} IMUData_t;
// 创建队列(长度10,每个元素大小 sizeof(IMUData_t))
QueueHandle_t xIMUQueue = xQueueCreate(10, sizeof(IMUData_t));
// 发送任务
void vSensorTask(void *pvParameters)
{
IMUData_t imu_data;
while(1)
{
ReadIMU(&imu_data);
// 发送到队列,如果队列满了就等待
xQueueSend(xIMUQueue, &imu_data, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(5));
}
}
// 接收任务
void vAttitudeTask(void *pvParameters)
{
IMUData_t imu_data;
while(1)
{
// 从队列接收,如果没有数据就阻塞
if(xQueueReceive(xIMUQueue, &imu_data, portMAX_DELAY) == pdTRUE)
{
UpdateAttitude(&imu_data);
}
}
}
队列的好处是天然带同步和缓冲。发送方和接收方不用关心对方什么时候处理,队列会自动管理。
核心原则:能用队列就别用全局变量。队列让数据流变得清晰,调试时一眼就能看出谁在发、谁在收。我后来重构老代码时,把所有全局变量都换成了队列,bug 率直接降了一半。
4.5 本章知识体系
下面这张图总结了 FreeRTOS 在飞控中的核心逻辑:
嗯,这一章的内容就到这里。FreeRTOS 是飞控开发的基石,把任务创建、优先级、堆栈、同步机制搞明白,后面写代码会顺手很多。记住我踩过的坑,别在堆栈和优先级上翻车。
最后一句心里话:学 RTOS 最好的方式就是动手写。找个开发板,创建三个任务,一个闪灯、一个读按键、一个串口打印,跑通了再往飞控上搬。别一上来就搞复杂的,容易把自己绕晕。
公众号:蓝海资料掘金营,微信deep3321