第4章 嵌入式实时操作系统(RTOS)基础:FreeRTOS任务管理、任务调度策略、任务间通信
好,咱们进入RTOS的世界。说实话,我早年做嵌入式开发时,一直用裸机跑循环。后来项目越来越复杂,一个按键要响应、一个传感器要采集、还要同时处理通信协议……裸机真的扛不住了。那时候我才真正体会到,为什么需要RTOS。
这一章,咱们就啃FreeRTOS这块硬骨头。它轻量、开源、资料多,是入门RTOS的最佳选择。我会把任务管理、调度策略、任务间通信这三个核心模块讲透。
4.1 任务管理——RTOS的基石
任务,说白了就是一个无限循环的函数。每个任务有自己的栈空间、优先级和状态。我刚开始用FreeRTOS时,犯过一个低级错误:任务函数里忘了加死循环,结果函数返回后系统直接崩溃。嗯,这个坑你们别踩。
4.1.1 任务创建与删除
创建任务用 xTaskCreate()。原型长这样:
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // 任务函数指针
const char * const pcName, // 任务名称(调试用)
configSTACK_DEPTH_TYPE usStackDepth, // 栈深度(单位:字)
void *pvParameters, // 传给任务的参数
UBaseType_t uxPriority, // 任务优先级(0~configMAX_PRIORITIES-1)
TaskHandle_t *pxCreatedTask // 返回的任务句柄
);
举个例子,创建一个LED闪烁任务:
void vLEDTask(void *pvParameters) {
while(1) {
GPIO_ToggleBits(GPIOA, GPIO_Pin_5);
vTaskDelay(pdMS_TO_TICKS(500)); // 延时500ms
}
}
TaskHandle_t xLEDTaskHandle = NULL;
xTaskCreate(vLEDTask, "LED", 128, NULL, 1, &xLEDTaskHandle);
uxTaskGetStackHighWaterMark() 查看实际使用量,再往下调。这样既省内存又不会溢出。
4.1.2 任务状态机
任务有四种状态:运行、就绪、阻塞、挂起。我画个简图帮大家理解:
┌──────────┐
│ 创建 │
└────┬─────┘
↓
┌──────────┐
┌───→│ 就绪 │←──────────┐
│ └────┬─────┘ │
│ │ 调度器选择 │
│ ↓ │
│ ┌──────────┐ 超时/事件 │
│ │ 运行 │───────────┤
│ └────┬─────┘ │
│ │ 等待事件 │
│ ↓ │
│ ┌──────────┐ │
│ │ 阻塞 │───────────┘
│ └──────────┘
│
└──── 挂起(vTaskSuspend)
这里有个关键点:只有就绪态的任务才能被调度器选中运行。阻塞态的任务在等待某个事件(延时、队列、信号量),事件没到就不参与调度。这设计很巧妙——避免了CPU空转。
4.2 任务调度策略——谁先跑?跑多久?
FreeRTOS支持三种调度策略。我一个个说。
| 调度策略 | 核心规则 | 适用场景 |
|---|---|---|
| 抢占式调度 | 高优先级任务就绪时,立即打断低优先级任务 | 实时性要求高的系统(默认配置) |
| 时间片轮转 | 同优先级任务轮流运行固定时间片 | 多个同等重要的任务共享CPU |
| 协作式调度 | 任务主动让出CPU(taskYIELD())才切换 | 对实时性要求不高的简单系统 |
4.2.1 抢占式调度——最常用
这是FreeRTOS的默认策略。优先级高的任务一旦就绪,立马抢走CPU。我在一个电机控制项目里就吃过亏:一个高优先级的中断服务任务把低优先级的通信任务饿死了,导致上位机收不到数据。后来我调整了优先级,把通信任务提到中等优先级,问题才解决。
4.2.2 时间片轮转——公平分配
当多个任务优先级相同时,调度器让它们轮流跑。每个任务跑一个时间片(通常1ms~10ms,由 configTICK_RATE_HZ 决定)。时间片到了,就切换到下一个同优先级任务。
举个例子:
// 两个任务优先级都是2
xTaskCreate(vTaskA, "TaskA", 128, NULL, 2, NULL);
xTaskCreate(vTaskB, "TaskB", 128, NULL, 2, NULL);
// 调度器会让TaskA和TaskB轮流运行,每个跑一个时间片
4.2.3 协作式调度——手动让权
这个用得少,但在某些低功耗场景下很实用。任务不主动调用 taskYIELD() 或阻塞API,调度器就不会切换。好处是上下文切换次数少,功耗低;坏处是一个任务死循环,整个系统就卡死了。
4.3 任务间通信——让任务们好好说话
任务之间不能直接访问对方的局部变量。那怎么传数据?怎么同步?FreeRTOS提供了队列、信号量、互斥量、任务通知等机制。我挑最常用的三个讲。
4.3.1 队列——数据搬运工
队列是FIFO结构,一个任务往里写,另一个任务往外读。数据是拷贝传递的,不是指针传递。这一点要注意——如果数据量大,拷贝开销会很高。
创建队列:
QueueHandle_t xQueue;
xQueue = xQueueCreate(10, sizeof(uint32_t)); // 队列长度10,每个元素4字节
发送数据:
uint32_t data = 100;
xQueueSend(xQueue, &data, portMAX_DELAY); // 队列满则永久等待
接收数据:
uint32_t received;
xQueueReceive(xQueue, &received, pdMS_TO_TICKS(100)); // 等待100ms
4.3.2 信号量——资源计数器
信号量分两种:二值信号量和计数型信号量。二值信号量像一把钥匙,只能一个人用;计数型信号量像停车场空位,可以多个。
典型用法——中断中释放信号量,任务中获取:
SemaphoreHandle_t xSemaphore;
// 创建二值信号量
xSemaphore = xSemaphoreCreateBinary();
// 中断服务函数中释放
void EXTI0_IRQHandler(void) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 任务中等待
void vTask(void *pvParameters) {
while(1) {
if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
// 处理中断事件
}
}
}
这个模式我用了无数次。中断只做标记,具体处理交给任务,既快又安全。
4.3.3 互斥量——解决资源争抢
互斥量是二值信号量的升级版,多了优先级继承机制。啥意思?就是当低优先级任务持有互斥量时,高优先级任务来抢,系统会临时提升低优先级任务的优先级,防止优先级反转。
我举个例子说明优先级反转有多坑:
任务A(优先级3)等待互斥量 → 任务B(优先级2)抢占CPU → 任务C(优先级1)持有互斥量
结果:A被B和C一起拖死,实时性全无
互斥量的使用和信号量类似,但注意:不能在中断中使用互斥量。因为优先级继承在中断上下文里没法实现。
| 机制 | 用途 | 能否在中断中使用 | 特点 |
|---|---|---|---|
| 队列 | 传递数据 | 可以(用FromISR版本) | 数据拷贝,适合小数据量 |
| 二值信号量 | 事件通知/同步 | 可以 | 简单高效,无优先级继承 |
| 计数信号量 | 资源计数 | 可以 | 可记录可用资源数量 |
| 互斥量 | 保护共享资源 | 不可以 | 有优先级继承,防反转 |
| 任务通知 | 轻量级信号量/消息 | 可以 | 更快,但只能点对点 |
4.4 实战中的几个坑
最后,我分享几个自己踩过的坑,你们遇到类似情况可以少走弯路。
- 栈溢出: 任务栈设太小,运行一段时间后莫名其妙死机。用
configCHECK_FOR_STACK_OVERFLOW开启检测,或者用uxTaskGetStackHighWaterMark()监控。 - 优先级反转: 我前面提过。用互斥量代替二值信号量保护共享资源,或者设计时让持有资源的任务优先级尽量高。
- 死锁: 两个任务互相等待对方释放资源。我习惯用
xSemaphoreTake()加超时参数,别用portMAX_DELAY死等。 - 中断中调用非FromISR版本API: 这会导致系统崩溃。记住,中断里只能用
xQueueSendFromISR()、xSemaphoreGiveFromISR()这些带FromISR后缀的函数。
嗯,这一章的内容就到这里。FreeRTOS的任务管理、调度策略和通信机制,是后续所有中间件集成的基础。把这些搞透了,后面讲消息队列、内存管理、驱动框架时,你会觉得轻松很多。