4、实时操作系统(RTOS)基础:FreeRTOS任务调度、中断管理、信号量与消息队列

各位同学,今天我们聊聊飞控系统的“心脏”——实时操作系统。说白了,飞控就是一台不停做决策的机器,它必须在规定时间内完成所有任务。如果晚了,飞机可能就翻了。我最早接触飞控时,用的是裸机循环,后来发现根本扛不住复杂任务,才转向了 FreeRTOS。今天我就把这块的核心内容掰开揉碎讲给你听。

4.1 任务调度:谁先跑,谁后跑?

FreeRTOS 的任务调度,核心就是“优先级抢占”。什么意思?高优先级的任务可以随时打断低优先级的任务。我习惯把任务分成几个等级:

  • 紧急任务:比如传感器数据采集、姿态解算,优先级最高。
  • 常规任务:比如控制输出、通信处理,优先级中等。
  • 后台任务:比如日志记录、状态显示,优先级最低。

你想想看,如果姿态解算被日志打印打断了,那飞机还能稳吗?肯定不行。所以调度策略必须清晰。

核心机制:每个任务都有一个优先级(0~configMAX_PRIORITIES-1),数值越大优先级越高。调度器永远运行当前就绪态中优先级最高的任务。

举个例子,我写过一个飞控任务创建代码:

// 创建姿态解算任务(优先级最高)
xTaskCreate(
    vAttitudeTask,      // 任务函数
    "Attitude",         // 任务名称
    256,                // 栈深度
    NULL,               // 参数
    5,                  // 优先级(最高)
    NULL                // 任务句柄
);

// 创建通信任务(优先级中等)
xTaskCreate(
    vCommTask,
    "Comm",
    128,
    NULL,
    3,
    NULL
);

// 创建日志任务(优先级最低)
xTaskCreate(
    vLogTask,
    "Log",
    64,
    NULL,
    1,
    NULL
);

这里要注意,栈深度一定要算好。我曾经因为栈给太小,导致任务跑飞,查了整整两天才找到原因。嗯,建议每个任务至少给 128 字,复杂任务给 256 字以上。

4.2 中断管理:别让中断“喧宾夺主”

中断是 RTOS 的“快车道”。传感器数据来了,必须立刻响应。但中断服务程序(ISR)里不能做太多事,否则会阻塞其他中断。我个人的经验是:ISR 里只做最轻量的操作,比如设置一个标志位,或者通过消息队列把数据扔给任务处理。

FreeRTOS 提供了专门的中断安全 API:

  • xQueueSendFromISR():在中断中发送消息
  • xSemaphoreGiveFromISR():在中断中释放信号量
  • portYIELD_FROM_ISR():在中断中触发任务切换

警告:千万不要在 ISR 中调用 vTaskDelay()xQueueReceive() 这类阻塞函数!这会导致系统崩溃。我见过有人这么干,结果整个飞控直接死机。

举个例子,陀螺仪数据中断处理:

void vGyroISR(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    uint32_t ulData;

    // 读取传感器数据
    ulData = readGyro();

    // 通过队列发送给任务
    xQueueSendFromISR(xGyroQueue, &ulData, &xHigherPriorityTaskWoken);

    // 如果接收任务优先级更高,触发切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

你看,ISR 里只做了两件事:读数据、发队列。剩下的解算工作交给任务去处理。这样既保证了实时性,又不会阻塞系统。

4.3 信号量:任务间的“红绿灯”

信号量,说白了就是一把锁。用来保护共享资源,或者同步任务执行顺序。FreeRTOS 支持两种信号量:

类型 用途 典型场景
二进制信号量 任务同步 中断通知任务处理数据
计数信号量 资源管理 控制多个外设访问

我遇到过一个问题:两个任务同时写一个全局变量,结果数据全乱了。后来用二进制信号量保护起来,问题就解决了。

// 创建信号量
SemaphoreHandle_t xSemaphore = xSemaphoreCreateBinary();

// 任务A:写数据
void vTaskA(void *pvParameters) {
    while(1) {
        // 等待信号量
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            // 写共享数据
            writeSharedData();
            // 释放信号量
            xSemaphoreGive(xSemaphore);
        }
    }
}

// 任务B:读数据
void vTaskB(void *pvParameters) {
    while(1) {
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            readSharedData();
            xSemaphoreGive(xSemaphore);
        }
    }
}

小技巧:信号量的超时时间别设成 portMAX_DELAY,除非你确定不会死锁。我习惯设成 100ms,超时后打印错误日志,方便调试。

4.4 消息队列:任务间的“快递员”

消息队列是 FreeRTOS 中最常用的通信方式。它像一个管道,一个任务往里扔数据,另一个任务从里面取数据。队列是 FIFO(先进先出)的,保证数据顺序不乱。

我设计飞控时,用消息队列串联了所有模块:

  • 传感器中断 → 队列1 → 姿态解算任务
  • 姿态解算任务 → 队列2 → 控制输出任务
  • 控制输出任务 → 队列3 → 通信任务

这样每个任务只关心自己的输入输出,耦合度很低。改一个模块,其他模块不用动。

// 创建队列(每个元素32字节,队列深度10)
QueueHandle_t xQueue = xQueueCreate(10, sizeof(uint32_t));

// 发送任务
void vSenderTask(void *pvParameters) {
    uint32_t ulValue = 100;
    while(1) {
        // 发送数据,超时100ms
        if(xQueueSend(xQueue, &ulValue, pdMS_TO_TICKS(100)) != pdPASS) {
            // 发送失败,队列满了
            vLogError("Queue full!");
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// 接收任务
void vReceiverTask(void *pvParameters) {
    uint32_t ulReceived;
    while(1) {
        // 接收数据,阻塞等待
        if(xQueueReceive(xQueue, &ulReceived, portMAX_DELAY) == pdPASS) {
            // 处理数据
            processData(ulReceived);
        }
    }
}

关键点:队列深度要根据数据产生速率来定。传感器 1kHz 输出,队列深度至少 10,否则容易丢数据。我一般留 50% 余量。

4.5 知识体系总览

下面这张图是我自己画的,把 FreeRTOS 的核心模块串起来了。你看一眼就能明白它们之间的关系:

FreeRTOS 核心模块关系图 中断管理 ISR → 信号量/队列 任务调度 优先级抢占 信号量 同步 / 互斥 消息队列 任务间通信 触发 通知 发送/接收 控制 中断驱动任务,任务通过信号量和队列协作

这张图你看懂了吗?中断是“触发器”,任务调度是“大脑”,信号量和队列是“血管”。四者配合,才能让飞控系统稳定运行。

4.6 避坑指南

最后,我分享几个实战中踩过的坑:

  • 栈溢出:任务栈给太小,运行一段时间就崩了。建议用 uxTaskGetStackHighWaterMark() 监控栈使用情况。
  • 优先级反转:低优先级任务占着资源,高优先级任务等不到。用互斥信号量(带优先级继承)可以解决。
  • 中断嵌套:FreeRTOS 默认中断嵌套深度有限,别在 ISR 里调用太多 API。
  • 队列满:发送数据时一定要检查返回值,否则数据丢了都不知道。

我的习惯:每个任务都加一个看门狗喂狗操作。如果任务卡死,看门狗会复位系统。这在飞控中特别重要,毕竟飞机不能在空中死机。

好了,FreeRTOS 的基础就讲到这里。记住,理论是死的,实践才是硬道理。回去把代码跑一遍,遇到问题再回来翻翻这节课。


专注资料整理