4. RTOS 核心概念:任务、调度器、内核对象
好,咱们进入正题。裸机编程玩熟了,你会发现一个瓶颈:CPU 利用率上不去,代码维护越来越痛苦。这时候 RTOS 就是你的救星。但 RTOS 不是魔法,它核心就三样东西——任务、调度器、内核对象。把这仨搞明白,你基本就入门了。
我记得第一次用 RTOS 时,总觉得“任务”不就是个函数吗?后来踩了坑才明白,任务远不止一个函数那么简单。
4.1 任务:RTOS 里的“独立线程”
任务,说白了就是一段独立执行的代码。每个任务都有自己的栈空间、优先级和状态。在裸机里,你只有一个 main 函数无限循环;在 RTOS 里,你可以有多个“main 函数”同时跑——当然,是分时跑的。
任务的核心属性:
- 任务控制块(TCB):内核用来管理任务的数据结构,存了栈指针、优先级、状态等
- 任务栈:每个任务独享的栈空间,大小要提前算好
- 优先级:数值越小优先级越高(常见做法),决定谁先跑
- 任务状态:运行、就绪、阻塞、挂起、休眠
任务状态切换是 RTOS 调度的基础。我画了张图,帮你理清这几种状态怎么跳转:
你想想看,裸机里一个 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 爆了,调度效率也下降。够用就好,别炫技。