4、实时操作系统基础:FreeRTOS任务管理、消息队列、信号量与互斥锁
各位同学,咱们今天聊点硬核的——FreeRTOS。说实话,我在嵌入式这行摸爬滚打十几年,接触过的RTOS不下七八种,但FreeRTOS始终是我个人最偏爱的一个。为什么?因为它轻量、开源、文档全,而且社区活跃。你想想看,一个只有几千行代码的内核,却能支撑起从智能灯泡到工业控制器的各种场景,这本身就是件很酷的事。
4.1 任务管理:RTOS的基石
任务,说白了就是一段独立执行的代码。在裸机编程里,我们习惯用一个大循环来回跑,但到了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 // 返回的任务句柄
);
嗯,这里有个坑——栈深度。我记得有次给一个客户做项目,他们写了个任务,栈深度设了128字,结果跑着跑着就死机了。查了半天,发现任务里有个递归函数,栈溢出了。所以我的建议是:栈深度宁多勿少,特别是任务里用了printf这类耗栈的操作。
4.1.2 任务状态与调度
FreeRTOS的任务有四种状态:运行态、就绪态、阻塞态、挂起态。你想想看,这就像我们平时的工作状态——有的人正在干活(运行态),有的人等着分配任务(就绪态),有的人在等某个资源(阻塞态),还有的人被老板叫去谈话了(挂起态)。
调度策略方面,FreeRTOS默认是抢占式调度。说白了就是:高优先级的任务来了,低优先级的立马让路。但这里有个问题——优先级反转。我遇到过最典型的场景:一个低优先级任务拿了互斥锁,结果被中等优先级任务抢占了,高优先级任务只能干等着。解决方案?用优先级继承的互斥锁,这个后面会讲。
4.2 消息队列:任务间的快递员
消息队列,说白了就是任务之间传递数据的管道。我习惯把它想象成一个环形缓冲区,生产者往里放数据,消费者往外取数据。FreeRTOS的队列是线程安全的,这意味着你不用自己加锁。
4.2.1 队列的创建与操作
// 创建一个能存放5个int型数据的队列
QueueHandle_t xQueue = xQueueCreate(5, sizeof(int));
// 发送数据(从ISR中要用xQueueSendFromISR)
xQueueSend(xQueue, &data, portMAX_DELAY);
// 接收数据
int receivedData;
xQueueReceive(xQueue, &receivedData, portMAX_DELAY);
这里有个细节:portMAX_DELAY 表示一直等,直到有数据。但如果你在中断服务函数里用这个参数,系统会直接崩溃。为什么?因为中断里不能阻塞。所以,ISR里一定要用 0 或者非常短的超时时间。
4.2.2 队列集:管理多个队列
当你的系统里有多个数据源时,比如一个传感器采集任务、一个按键扫描任务,它们都要往主控任务发数据。这时候可以用队列集(Queue Set)。它就像一个总台,哪个队列有数据来了,它就通知你。
QueueSetHandle_t xQueueSet = xQueueCreateSet(5);
xQueueAddToSet(xSensorQueue, xQueueSet);
xQueueAddToSet(xKeyQueue, xQueueSet);
// 等待任意队列有数据
QueueSetMemberHandle_t xActivatedQueue;
xActivatedQueue = xQueueSelectFromSet(xQueueSet, portMAX_DELAY);
// 从激活的队列中读取数据
if(xActivatedQueue == xSensorQueue) {
xQueueReceive(xSensorQueue, &sensorData, 0);
}
4.3 信号量:资源的通行证
信号量,说白了就是一个计数器。它用来管理有限资源的访问。比如系统里只有3个串口,但可能有5个任务想用,这时候信号量就派上用场了。
4.3.1 二值信号量与计数信号量
二值信号量只有0和1两种状态,适合做同步。比如一个任务等待另一个任务完成初始化:
// 创建二值信号量
SemaphoreHandle_t xSemaphore = xSemaphoreCreateBinary();
// 任务A:等待初始化完成
xSemaphoreTake(xSemaphore, portMAX_DELAY);
// 任务B:初始化完成后释放
xSemaphoreGive(xSemaphore);
计数信号量则适合管理多个资源。比如有3个缓冲区:
SemaphoreHandle_t xBufferSem = xSemaphoreCreateCounting(3, 3);
// 初始有3个可用缓冲区
// 使用缓冲区前
xSemaphoreTake(xBufferSem, portMAX_DELAY);
// 使用完后
xSemaphoreGive(xBufferSem);
4.4 互斥锁:解决资源竞争
互斥锁(Mutex)和二值信号量很像,但有个关键区别——互斥锁有优先级继承机制。什么意思?就是当一个低优先级任务持有互斥锁时,如果有高优先级任务来请求这个锁,系统会临时把低优先级任务的优先级提升到和高优先级一样,防止被中间优先级的任务插队。
4.4.1 互斥锁的使用
// 创建互斥锁
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();
// 访问共享资源前
xSemaphoreTake(xMutex, portMAX_DELAY);
// 操作共享资源
sharedData++;
// 释放互斥锁
xSemaphoreGive(xMutex);
这里有个铁律:谁拿了锁,谁就必须释放锁。我见过有人在一个任务里拿锁,然后在另一个任务里释放,结果系统直接死锁。还有,不要在中断里使用互斥锁——中断里不能阻塞,而互斥锁的优先级继承机制需要上下文切换。
4.4.2 死锁的预防
死锁,说白了就是两个任务互相等对方释放资源。比如任务A持有锁1,等锁2;任务B持有锁2,等锁1。结果两个都卡死了。
我的经验是:尽量使用超时参数,不要用 portMAX_DELAY。比如:
if(xSemaphoreTake(xMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
// 成功拿到锁
} else {
// 超时处理,记录错误日志
}
另外,锁的粒度要适中。锁的范围太大,会影响系统实时性;锁的范围太小,又容易出bug。我一般遵循「最小临界区」原则——只在真正需要保护的那几行代码前后加锁。
4.5 综合示例:传感器数据采集系统
最后,咱们看一个综合例子。假设系统里有三个任务:传感器采集任务、数据处理任务、日志输出任务。它们通过消息队列和信号量协作。
// 任务1:传感器采集
void vSensorTask(void *pvParameters) {
int sensorData;
for(;;) {
sensorData = readSensor();
// 发送到数据处理队列
xQueueSend(xDataQueue, &sensorData, 0);
vTaskDelay(pdMS_TO_TICKS(100)); // 10Hz采集
}
}
// 任务2:数据处理
void vProcessTask(void *pvParameters) {
int data;
for(;;) {
if(xQueueReceive(xDataQueue, &data, pdMS_TO_TICKS(500)) == pdTRUE) {
// 处理数据,需要互斥访问日志缓冲区
xSemaphoreTake(xLogMutex, portMAX_DELAY);
logBuffer[logIndex++] = data;
xSemaphoreGive(xLogMutex);
// 通知日志任务
xSemaphoreGive(xLogSemaphore);
}
}
}
// 任务3:日志输出
void vLogTask(void *pvParameters) {
for(;;) {
xSemaphoreTake(xLogSemaphore, portMAX_DELAY);
xSemaphoreTake(xLogMutex, portMAX_DELAY);
outputLog(logBuffer, logIndex);
logIndex = 0;
xSemaphoreGive(xLogMutex);
}
}
这个例子虽然简单,但涵盖了任务管理、消息队列、信号量和互斥锁的典型用法。你想想看,如果不用RTOS,光靠裸机轮询,要实现同样的功能得写多少状态机?
好了,这一章的内容就到这里。下一章咱们聊聊内存管理——FreeRTOS的堆栈是怎么分配的,以及如何避免内存碎片。到时候我会分享一个我踩过的坑,保证让你印象深刻。