4. RTOS 核心概念:任务、调度器、内核对象

好,咱们进入正题。裸机编程玩熟了,你会发现一个瓶颈:CPU 利用率上不去,代码维护越来越痛苦。这时候 RTOS 就是你的救星。但 RTOS 不是魔法,它核心就三样东西——任务、调度器、内核对象。把这仨搞明白,你基本就入门了。

我记得第一次用 RTOS 时,总觉得“任务”不就是个函数吗?后来踩了坑才明白,任务远不止一个函数那么简单。

4.1 任务:RTOS 里的“独立线程”

任务,说白了就是一段独立执行的代码。每个任务都有自己的栈空间、优先级和状态。在裸机里,你只有一个 main 函数无限循环;在 RTOS 里,你可以有多个“main 函数”同时跑——当然,是分时跑的。

任务的核心属性:

  • 任务控制块(TCB):内核用来管理任务的数据结构,存了栈指针、优先级、状态等
  • 任务栈:每个任务独享的栈空间,大小要提前算好
  • 优先级:数值越小优先级越高(常见做法),决定谁先跑
  • 任务状态:运行、就绪、阻塞、挂起、休眠

任务状态切换是 RTOS 调度的基础。我画了张图,帮你理清这几种状态怎么跳转:

任务状态机 运行态 就绪态 阻塞态 挂起态 调度器选中 时间片用完 等待资源 资源就绪 调用挂起 API 恢复任务 提示:阻塞态是 RTOS 提高 CPU 利用率的关键——任务等资源时不占 CPU

你想想看,裸机里一个 delay() 就把 CPU 卡死了。RTOS 里任务调用延迟函数,内核直接把它丢进阻塞态,让别的任务跑。这才是真正的“并发”。

任务栈大小怎么定?

我习惯的做法:先给个保守值(比如 512 字节),跑起来后用内核自带的栈使用统计 API 看峰值。不够就加,多了就减。别一开始就给 4KB,嵌入式设备内存金贵着呢。

4.2 调度器:谁该跑,跑多久

调度器是 RTOS 的大脑。它决定哪个任务上 CPU,跑多长时间。常见的调度策略就两种:

调度策略 原理 适用场景 我踩过的坑
抢占式调度 高优先级任务随时打断低优先级任务 实时性要求高的系统 优先级反转,差点让电机失控
时间片轮转 同优先级任务轮流跑固定时间片 多个同等重要的任务 时间片设太短,上下文切换开销爆炸

实际项目中,抢占式调度是主流。为什么?因为嵌入式系统里总有那么一两个紧急任务——比如按键响应、电机急停。你不能让它们等。

但抢占式调度有个经典问题:优先级反转。低优先级任务拿着锁,中优先级任务抢跑,高优先级任务反而被堵死。我曾经在一个电机控制项目里遇到这问题,电机直接抖成筛子。解决方案?要么用优先级继承协议,要么用互斥锁(后面会讲)。

调度器选型避坑:

  • 别在中断服务函数里调用阻塞 API——调度器管不了中断
  • 任务优先级别超过 32 级,太多级反而增加调度开销
  • 同优先级任务别超过 3-4 个,否则时间片轮转效率下降

4.3 内核对象:任务间的“通信工具”

任务之间不是孤岛。它们需要交换数据、同步动作。内核对象就是干这个的。最常用的三个:信号量、队列、互斥锁。

4.3.1 信号量:轻量级“通知机制”

信号量就是个计数器。任务 A 释放信号量(计数值+1),任务 B 获取信号量(计数值-1)。如果计数值为 0,获取任务就阻塞。

我常用信号量做两件事:

  • 二值信号量:通知事件发生。比如按键按下,ISR 给任务发信号量
  • 计数信号量:管理资源池。比如 5 个串口缓冲区,谁拿谁用
// FreeRTOS 信号量示例
SemaphoreHandle_t xSemaphore = NULL;

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

// 任务 A:等待按键
void Task_Button(void *pvParameters) {
    while(1) {
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            // 按键被按下,处理事件
            ProcessKeyEvent();
        }
    }
}

// ISR:按键中断
void EXTI0_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

信号量使用建议:

ISR 里只能用 FromISR 结尾的 API。我见过新手直接在中断里调 xSemaphoreTake(),系统直接死机。记住:中断里只给信号量,不取信号量。

4.3.2 队列:任务间的“数据管道”

信号量只能传“有没有事”,队列能传“什么事”。队列是 FIFO 结构,任务 A 往队列里塞数据,任务 B 从队列里取数据。

队列的典型应用场景:

  • 传感器数据采集:ISR 把数据塞队列,处理任务慢慢取
  • 命令传递:UI 任务把用户指令发队列,执行任务处理
  • 日志输出:多个任务往日志队列写,一个日志任务统一输出
// 队列示例:传递结构体
typedef struct {
    uint8_t  sensor_id;
    float    temperature;
    uint32_t timestamp;
} SensorData_t;

QueueHandle_t xSensorQueue;

// 创建队列,每个元素大小 sizeof(SensorData_t),深度 10
xSensorQueue = xQueueCreate(10, sizeof(SensorData_t));

// ISR 中发送数据
void ADC_IRQHandler(void) {
    SensorData_t data;
    data.sensor_id = 1;
    data.temperature = GetADCValue();
    data.timestamp = HAL_GetTick();
    
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xQueueSendFromISR(xSensorQueue, &data, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务中接收数据
void Task_SensorProcess(void *pvParameters) {
    SensorData_t received;
    while(1) {
        if(xQueueReceive(xSensorQueue, &received, pdMS_TO_TICKS(100)) == pdTRUE) {
            // 处理传感器数据
            ProcessSensorData(&received);
        }
    }
}

队列使用注意事项:

  • 队列深度别设太大,浪费 RAM。我一般先设 5-10,跑起来看有没有丢数据
  • 队列元素用传值方式,别传指针——指针指向的内存可能被其他任务改了
  • 队列满时发送会阻塞,小心死锁

4.3.3 互斥锁:解决“资源争抢”

互斥锁和信号量很像,但有个关键区别:互斥锁支持优先级继承。什么意思?就是当低优先级任务拿着锁时,高优先级任务来等锁,内核会临时提升低优先级任务的优先级,防止中优先级任务插队。这就是解决优先级反转的利器。

互斥锁的使用场景很明确:

  • 保护共享资源:比如全局变量、外设寄存器
  • 防止重入:某个函数同一时间只能被一个任务调用
  • 事务操作:需要原子性完成的读写操作
// 互斥锁示例:保护共享外设
SemaphoreHandle_t xMutex;

void Task_UART_Send(void *pvParameters) {
    char *msg = (char*)pvParameters;
    while(1) {
        // 获取互斥锁
        if(xSemaphoreTake(xMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
            // 安全使用 UART
            HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), 100);
            // 释放互斥锁
            xSemaphoreGive(xMutex);
        } else {
            // 超时处理
            LogError("Failed to get UART mutex");
        }
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

三种内核对象对比:

对象 传什么 典型用途 能否在 ISR 用
信号量 计数值(通知) 事件通知、资源计数 只能 Give
队列 数据(任意类型) 数据传递、命令分发 只能 Send
互斥锁 所有权 保护共享资源 不能

嗯,这里要注意:互斥锁不能在 ISR 里用。为什么?因为互斥锁的优先级继承机制依赖任务调度,中断里没有任务上下文。你如果在 ISR 里调 xSemaphoreGiveFromISR 给互斥锁,行为是未定义的——我见过有人这么干,系统直接跑飞。

最后说一句:内核对象不是越多越好。每个信号量、队列、互斥锁都要占用 RAM。我见过一个项目,开发者为了“灵活”创建了 50 多个信号量,结果 RAM 爆了,调度效率也下降。够用就好,别炫技。