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 在飞控中的核心逻辑 任务创建 xTaskCreate() 任务调度 抢占式优先级调度 优先级配置 高→低:5→1 堆栈配置 256字起步,检查水位线 同步机制 临界区 关中断保护 信号量 二值/计数信号量 队列 任务间通信首选 飞控系统:传感器采集 → 姿态解算 → 控制输出,每个环节都是一个独立任务

嗯,这一章的内容就到这里。FreeRTOS 是飞控开发的基石,把任务创建、优先级、堆栈、同步机制搞明白,后面写代码会顺手很多。记住我踩过的坑,别在堆栈和优先级上翻车。

最后一句心里话:学 RTOS 最好的方式就是动手写。找个开发板,创建三个任务,一个闪灯、一个读按键、一个串口打印,跑通了再往飞控上搬。别一上来就搞复杂的,容易把自己绕晕。


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