学习嵌入式硬件开发(三):RTOS多任务系统设计 —— FreeRTOS从入门到实战
裸机 vs RTOS:一个真实对比
想象你正在开发一台智能加湿器,需要同时处理:
- 每100ms读取湿度传感器
- 每1秒更新LCD显示
- 处理按键事件(短按切换模式,长按3秒关机)
- WiFi通信(接收App指令)
- PWM控制雾化片
裸机大循环写法:
void main() {
while(1) {
if (timer_100ms_flag) {
timer_100ms_flag = 0;
read_humidity_sensor();
}
if (timer_1s_flag) {
timer_1s_flag = 0;
update_display();
}
check_button();
wifi_process();
// 如果wifi_process()需要等待响应500ms?
// 整个系统卡住500ms,湿度不更新、显示不刷新
}
}
问题很明确:任何任务的阻塞都会拖累整个系统。在裸机前后台系统中,主循环里任何一个耗时操作都会导致所有功能停摆。用RTOS后,每个任务独立调度,WiFi等待响应时操作系统自动切换到其他任务——这就是实时操作系统的核心价值。
FreeRTOS核心:任务管理
任务创建的每一个参数
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // 任务函数
const char *pcName, // 任务名(调试用)
uint16_t usStackDepth, // 栈大小(字,不是字节!)
void *pvParameters, // 参数指针
UBaseType_t uxPriority, // 优先级(数字越大越高)
TaskHandle_t *pxCreatedTask // 任务句柄
);
栈大小怎么定?这是一个非常实际的问题。栈大小 ≈ 函数调用深度 × 局部变量大小 + 中断嵌套开销。在智能小家电PCBA方案开发中,我们总结的经验值:
- 简单任务(操作GPIO):128字 = 512字节
- 中等任务(含printf):256字 = 1024字节
- 复杂任务(含JSON解析):512字以上
configCHECK_FOR_STACK_OVERFLOW 宏,设为2会在任务切换时检查栈顶是否被破坏。但最直接的方法:先用一个大栈(如512),跑起来后用 uxTaskGetStackHighWaterMark() 查看实际使用了多少,再调整到实际用量的1.2-1.5倍即可。
任务优先级:避免优先级反转
假设有三个任务:
- Task A(最高优先级):处理按键
- Task B(中优先级):更新显示
- Task C(低优先级):持有互斥信号量,正在操作共享数据
Task A等待Task C释放信号量,但Task C被Task B抢占。结果:Task A被Task B间接阻塞——这就是优先级反转。这个问题在航天史上造成过严重事故(火星探路者号的系统复位),在嵌入式产品中也同样致命。
解决方案:使用互斥信号量(Mutex),FreeRTOS的Mutex自动启用优先级继承——Task C临时提升到Task A的优先级,快速完成操作后释放信号量。
信号量:同步与互斥
二值信号量实现按键去抖
SemaphoreHandle_t xButtonSemaphore;
// 按键中断服务函数
void EXTI0_IRQHandler(void) {
if (EXTI->PR & EXTI_PR_PR0) {
EXTI->PR = EXTI_PR_PR0;
// 在中断中Give信号量
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xButtonSemaphore,
&xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
}
// 按键任务
void vButtonTask(void *pvParameters) {
while(1) {
if (xSemaphoreTake(xButtonSemaphore, pdMS_TO_TICKS(50))) {
// 消抖:50ms后再次确认
vTaskDelay(pdMS_TO_TICKS(50));
if (GPIO_PIN_IS_LOW) {
handle_button_press();
}
}
}
}
这个模式的精妙之处:中断只做最轻量的事——Give信号量就退出。按键去抖的50ms延时在任务上下文中完成,不影响其他中断的响应。如果把延时放在ISR里,那50ms内所有中断都被阻塞,系统直接瘫痪。
消息队列:任务间数据传递
实际场景:传感器数据发布
typedef struct {
float temperature;
float humidity;
uint32_t timestamp;
} SensorData_t;
QueueHandle_t xSensorQueue;
// 传感器任务发送数据
void vSensorTask(void *pv) {
SensorData_t data;
while(1) {
data.temperature = read_temperature();
data.humidity = read_humidity();
data.timestamp = xTaskGetTickCount();
xQueueSend(xSensorQueue, &data, 0); // 非阻塞发送
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
// 显示任务接收数据
void vDisplayTask(void *pv) {
SensorData_t data;
while(1) {
if (xQueueReceive(xSensorQueue, &data, pdMS_TO_TICKS(2000))) {
update_display(data.temperature, data.humidity);
} else {
// 超时:传感器可能故障,显示"---"
show_error();
}
}
}
消息队列实现了生产者-消费者模式的解耦。传感器任务只管生产数据,显示任务只管消费数据,两者通过队列异步通信。如果传感器采集变慢(比如DHT22在特定湿度下响应时间延长),显示任务不会卡住,而是触发超时处理。
FreeRTOS核心概念速览
| 概念 | 作用 | 典型场景 |
|---|---|---|
| 任务(Task) | 独立执行单元 | 显示刷新任务、传感器采集任务 |
| 信号量(Semaphore) | 同步/互斥 | 按键中断释放信号量,任务获取后处理 |
| 消息队列(Queue) | 传递数据 | UART接收中断往队列写数据,解析任务从队列读 |
| 软件定时器(Timer) | 定时回调 | 每100ms采集一次温度 |
实战踩坑:vTaskDelay与vTaskDelayUntil
很多人用 vTaskDelay(1000) 觉得"每1秒执行一次"。但实际上,vTaskDelay 是"从现在起等待1000ms",如果任务执行了100ms,实际周期是1100ms。
正确做法:
void vTask(void *pv) {
TickType_t xLastWakeTime = xTaskGetTickCount();
while(1) {
// 执行任务
do_work();
// 精确延时
vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(1000));
}
}
vTaskDelayUntil 保证任务以精确周期运行,不受任务执行时间的影响。这个细微差别在电机控制、PID算法等对周期要求严格的场景中至关重要。我们曾在智能厨电温控方案中踩过这个坑——用 vTaskDelay 做PID计算周期,结果每次计算时间抖动导致控温精度从±1℃退化到±3℃。改用 vTaskDelayUntil 后精度恢复。