4、实时操作系统(RTOS)基础:FreeRTOS任务创建与调度、信号量、消息队列、中断与任务的同步机制

各位同学,咱们今天聊点硬核的。RTOS,说白了就是给单片机请了个“管家”。你想想看,裸机编程时,所有代码都在一个大循环里转,一旦某个函数卡住了,整个系统就瘫痪了。而有了RTOS,我们可以把不同的功能拆成独立的任务,让它们“分时复用”CPU。

我个人习惯用FreeRTOS,因为它轻量、免费,而且生态好。今天我就带大家把这几个核心机制捋一遍:任务怎么创建、怎么调度,任务之间怎么通信,以及中断怎么跟任务“握手”。

核心观点:RTOS不是万能的,但用好了,你的嵌入式系统会从“单线程的蛮力干活”变成“多线程的优雅协作”。

4.1 任务创建与调度:让CPU“分身有术”

任务,在FreeRTOS里就是一个永不返回的C函数。它的核心结构长这样:

void vTaskFunction(void *pvParameters)
{
    // 任务初始化代码
    for( ;; )
    {
        // 任务主体代码
        vTaskDelay(pdMS_TO_TICKS(1000)); // 延时1秒
    }
}

创建任务用 xTaskCreate() 函数。我刚开始学的时候,总搞不清最后一个参数是干嘛的。其实它就是给任务函数传参用的,比如你可以传一个结构体指针进去。

TaskHandle_t xTaskHandle = NULL;
xTaskCreate(vTaskFunction,       // 任务函数
            "Task1",             // 任务名称(调试用)
            1024,                // 栈深度(单位:字)
            NULL,                // 参数
            1,                   // 优先级(数字越大优先级越高)
            &xTaskHandle);       // 任务句柄

避坑指南:我曾经在项目里把栈深度设得太小,结果任务跑着跑着就崩了。调试了整整一天才发现是栈溢出。记住:任务栈大小要留余量,尤其是任务里用了printf这类吃栈的函数。

调度机制,说白了就是“谁优先级高谁先跑”。FreeRTOS用的是抢占式调度。什么意思?就是高优先级任务一旦就绪,立马把低优先级任务踢出去。但这里有个坑:如果两个任务优先级相同,它们就轮流跑,每次一个时间片(通常是1ms)。

为什么会这样?因为CPU只有一个核,它只能通过快速切换来模拟“同时运行”。你想想看,1ms切一次,人眼根本感觉不到,所以看起来就像多个任务在并行。

4.2 信号量:任务间的“交通信号灯”

信号量,说白了就是一个计数器。它用来控制多个任务对共享资源的访问。比如,一个串口只能被一个任务占用,这时候就需要信号量来“锁”住它。

FreeRTOS提供了两种信号量:二值信号量和计数信号量。二值信号量只有0和1两种状态,适合做互斥锁。计数信号量可以累加,适合管理多个同类资源。

SemaphoreHandle_t xSemaphore = NULL;

// 创建二值信号量
xSemaphore = xSemaphoreCreateBinary();

// 任务A:获取信号量
if( xSemaphoreTake( xSemaphore, portMAX_DELAY ) == pdTRUE )
{
    // 访问共享资源
    // ...
    // 释放信号量
    xSemaphoreGive( xSemaphore );
}

注意:千万不要在中断服务函数里调用 xSemaphoreTake()!中断里只能调用带“FromISR”后缀的API,比如 xSemaphoreGiveFromISR()。我见过有人在这上面栽跟头,系统直接死机。

我在项目中遇到过一个问题:两个任务同时访问一个全局变量,结果数据被覆盖了。后来用信号量一锁,问题就解决了。嗯,这里要注意:信号量保护的是“临界区”,也就是访问共享资源的那段代码,而不是整个任务。

4.3 消息队列:任务间的“邮局”

消息队列,就是任务之间传递数据的“管道”。一个任务往里扔数据,另一个任务从里面取数据。队列是先进先出的,保证数据顺序不乱。

QueueHandle_t xQueue = NULL;

// 创建队列,每个元素4字节,队列深度10
xQueue = xQueueCreate( 10, sizeof( uint32_t ) );

// 发送任务
uint32_t ulValue = 100;
xQueueSend( xQueue, &ulValue, 0 );

// 接收任务
uint32_t ulReceivedValue;
xQueueReceive( xQueue, &ulReceivedValue, portMAX_DELAY );

我个人习惯用消息队列来解耦任务。比如,一个传感器采集任务把数据扔进队列,一个显示任务从队列里取数据刷新屏幕。这样两个任务互不干扰,改起来也方便。

技巧:队列的深度要根据数据产生速度和消费速度来定。如果生产者太快,消费者太慢,队列会满。这时候 xQueueSend() 会返回 errQUEUE_FULL。你可以选择阻塞等待,或者直接丢弃旧数据。

你想想看,如果没有消息队列,任务之间怎么传数据?用全局变量?那就要加信号量保护,麻烦得很。消息队列自带同步机制,省心多了。

4.4 中断与任务的同步机制:让“紧急事件”被及时处理

中断是嵌入式系统的灵魂。但中断服务函数(ISR)里不能做太多事,否则会影响系统实时性。正确的做法是:ISR里只做最紧急的事(比如读取硬件寄存器),然后把“重活”交给任务去干。

怎么交?用信号量或消息队列。ISR里释放一个信号量,任务在等待这个信号量,一旦信号量可用,任务就立刻执行。

// 中断服务函数
void vISR_Handler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    // 读取硬件数据
    uint32_t ulData = REG_READ(0x4000);
    
    // 发送数据到队列(FromISR版本)
    xQueueSendFromISR( xQueue, &ulData, &xHigherPriorityTaskWoken );
    
    // 如果任务优先级高于当前任务,请求上下文切换
    portYIELD_FROM_ISR( xHigherPriorityTaskWoken );
}

警告:ISR里调用FreeRTOS API时,一定要用带“FromISR”后缀的版本。而且,最后一个参数 xHigherPriorityTaskWoken 不能设为NULL,否则系统可能不会及时切换任务。

我曾经在一个电机控制项目里,把中断里直接处理了PID计算,结果中断执行时间太长,导致其他中断丢失。后来改成ISR只采集数据,通过消息队列发给任务去算PID,问题就解决了。嗯,这就是“中断快进快出”的原则。

4.5 知识体系总览

下面这张图,是我自己总结的FreeRTOS核心机制关系图。你看一眼,就能明白任务、信号量、队列、中断之间是怎么配合的。

FreeRTOS核心机制关系图 任务A(生产者) 任务B(消费者) 消息队列 (数据管道) xQueueSend xQueueReceive 信号量(互斥锁) xSemaphoreTake xSemaphoreGive 中断服务函数(ISR) xQueueSendFromISR xSemaphoreGiveFromISR 实线:数据流 虚线:同步/互斥

从这张图你可以看到:任务A和任务B通过消息队列传递数据,通过信号量保护共享资源。中断服务函数则通过带“FromISR”后缀的API,与任务进行通信。整个系统就是这样协同工作的。

总结一下:任务创建是基础,调度是规则,信号量和消息队列是通信手段,中断同步是“紧急通道”。把这四个点吃透了,FreeRTOS你就掌握了七八成。

好了,这一章的内容就到这儿。记住:理论要结合实践,回去拿块开发板,把今天讲的代码敲一遍,跑一跑,感受一下任务切换和通信的过程。遇到问题别慌,先查栈溢出,再查优先级,最后查API调用对不对。嗯,调试RTOS问题,其实就这三板斧。


公众号:蓝海资料掘金营,微信deep3321