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这类耗栈的操作。

避坑指南:我曾经在STM32上调试一个多任务系统,任务栈设了256字,结果一运行就HardFault。后来用vTaskList()一看,有个任务的栈使用率已经飙到95%了。所以,一定要在调试阶段开启栈溢出检测(configCHECK_FOR_STACK_OVERFLOW),别等到量产了才发现问题。

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 或者非常短的超时时间。

个人经验:我习惯把队列长度设成2的幂次,比如4、8、16。这样底层可以用位运算取模,效率更高。虽然FreeRTOS内部已经优化过了,但养成这个习惯没坏处。

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);
注意:我曾经在一个项目中用二值信号量做中断与任务的同步,结果发现中断释放信号量后,任务要等好几个毫秒才能响应。后来查资料才知道,FreeRTOS的tick中断默认是1ms一次,任务切换只能发生在tick中断里。所以,如果对实时性要求高,可以考虑在中断里直接调用taskYIELD()

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。我一般遵循「最小临界区」原则——只在真正需要保护的那几行代码前后加锁。

避坑指南:我曾经在一个项目里用互斥锁保护一个全局结构体,结果发现某个任务在持有锁的时候调用了vTaskDelay(),导致其他高优先级任务长时间无法访问这个结构体。后来我把临界区拆成了更小的片段,问题就解决了。记住:持有锁的时间越短越好

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的堆栈是怎么分配的,以及如何避免内存碎片。到时候我会分享一个我踩过的坑,保证让你印象深刻。