FreeRTOS任务调度详解:任务、队列、信号量和互斥锁
一、前言
在裸机开发中,程序通常是按照顺序执行的。
例如:
初始化外设 ↓ while(1)循环 ↓ 不断轮询按键、串口、传感器、电机状态这种方式在功能简单时没有问题,但当系统功能越来越多时,就会变得很难维护。
比如一个嵌入式设备同时需要完成以下任务:
采集传感器数据 处理串口通信 刷新显示屏 控制电机运行 保存数据到Flash 处理按键输入 上传数据到服务器如果全部放在一个while(1)中轮询,就会出现很多问题:
程序结构混乱 某个功能阻塞后影响其他功能 任务优先级不好控制 实时性不好保证 代码维护难度大这时候就可以引入 RTOS,也就是实时操作系统。
FreeRTOS 是嵌入式开发中非常常见的轻量级实时操作系统,它可以把不同功能拆分成多个任务,让调度器根据优先级、阻塞状态和时间片机制来决定哪个任务获得 CPU 执行权。
本文主要介绍 FreeRTOS 中几个最核心的知识点:
任务 Task 任务调度 Scheduler 任务状态切换 队列 Queue 信号量 Semaphore 互斥锁 Mutex 优先级翻转和优先级继承二、什么是 FreeRTOS
FreeRTOS 是一个轻量级、开源、可移植的实时操作系统内核,主要用于单片机和嵌入式系统。
它的特点包括:
体积小 可裁剪 实时性强 移植方便 适合资源受限的 MCU在 FreeRTOS 中,开发者可以把一个复杂系统拆分成多个任务。
例如:
任务1:LED闪烁 任务2:串口接收 任务3:传感器采集 任务4:电机控制 任务5:数据上传每个任务看起来都像一个独立运行的小程序,但实际上单核 MCU 同一时刻只能运行一个任务。
FreeRTOS 的调度器会在多个任务之间快速切换,让我们感觉这些任务像是在“同时运行”。
可以简单理解为:
FreeRTOS 的核心作用,就是让多个任务有序、可控地共享 CPU。图1 FreeRTOS 基础与任务调度简介
FreeRTOS 的核心组成包括任务、调度器、Tick 节拍、中断、队列、信号量和互斥锁。任务负责执行具体逻辑,调度器负责决定哪个任务运行。
三、FreeRTOS 的核心组成
FreeRTOS 常见核心组件包括:
任务 Task 调度器 Scheduler Tick节拍 队列 Queue 信号量 Semaphore 互斥锁 Mutex 软件定时器 Timer 事件组 Event Group本文重点介绍最常用的几个:
| 组件 | 作用 |
|---|---|
| 任务 Task | 程序执行的基本单位 |
| 调度器 Scheduler | 决定哪个任务获得 CPU |
| 队列 Queue | 用于任务之间传递数据 |
| 信号量 Semaphore | 用于任务同步、事件通知、资源计数 |
| 互斥锁 Mutex | 用于保护共享资源 |
| Tick 节拍 | 为系统提供时间基准 |
四、什么是任务 Task
任务是 FreeRTOS 中最基本的执行单元。
可以把一个任务理解成一个独立运行的函数。
例如:
voidLedTask(void*pvParameters){while(1){LED_Toggle();vTaskDelay(pdMS_TO_TICKS(500));}}这个任务负责每 500ms 翻转一次 LED。
FreeRTOS 中的任务一般都是一个死循环:
while(1){// 任务逻辑}因为任务本质上是长期运行的程序模块。
一个任务通常包含:
任务函数 任务栈 任务优先级 任务句柄 任务状态 任务上下文其中:
任务函数:任务具体执行的代码 任务栈:保存局部变量、函数调用信息 任务优先级:决定调度时谁更容易运行 任务句柄:用于后续操作任务 任务上下文:保存寄存器、PC指针等现场信息五、任务创建函数 xTaskCreate
FreeRTOS 中常用xTaskCreate()创建任务。
函数原型如下:
BaseType_txTaskCreate(TaskFunction_t pxTaskCode,constchar*constpcName,constconfigSTACK_DEPTH_TYPE usStackDepth,void*pvParameters,UBaseType_t uxPriority,TaskHandle_t*pxCreatedTask);参数说明:
| 参数 | 说明 |
|---|---|
| pxTaskCode | 任务函数入口 |
| pcName | 任务名称,主要用于调试 |
| usStackDepth | 任务栈大小 |
| pvParameters | 传递给任务的参数 |
| uxPriority | 任务优先级 |
| pxCreatedTask | 任务句柄 |
示例:
TaskHandle_t LedTaskHandle;voidLedTask(void*pvParameters){while(1){LED_Toggle();vTaskDelay(pdMS_TO_TICKS(500));}}intmain(void){System_Init();xTaskCreate(LedTask,"LedTask",128,NULL,2,&LedTaskHandle);vTaskStartScheduler();while(1){}}这里创建了一个 LED 任务,优先级为 2,栈大小为 128。
注意:
vTaskStartScheduler() 启动调度器后,FreeRTOS 才开始调度任务。六、任务优先级
FreeRTOS 使用优先级决定任务调度顺序。
一般情况下:
优先级数值越大,任务优先级越高。例如:
任务A:优先级3 任务B:优先级2 任务C:优先级1如果任务 A、B、C 都处于就绪态,那么调度器会优先运行任务 A。
优先级的最大范围由配置项决定:
#defineconfigMAX_PRIORITIES5如果configMAX_PRIORITIES = 5,那么任务优先级范围通常是:
0 ~ 4其中:
0 通常是最低优先级 4 是最高优先级设计任务优先级时要注意:
实时性高的任务优先级可以高一些; 耗时长的任务优先级不要过高; 普通后台任务优先级可以低一些; 不要把所有任务都设置成最高优先级。七、FreeRTOS 的任务调度机制
FreeRTOS 常见调度方式包括:
抢占式调度 时间片轮转 阻塞让出 CPU1. 抢占式调度
如果启用了抢占式调度:
#defineconfigUSE_PREEMPTION1当一个更高优先级任务进入就绪态时,它可以立即抢占当前正在运行的低优先级任务。
例如:
低优先级任务正在运行 ↓ 高优先级任务被事件唤醒 ↓ 调度器切换到高优先级任务这就是抢占式调度。
2. 时间片轮转
如果多个任务优先级相同,并且都处于就绪态,FreeRTOS 可以通过时间片轮转让它们轮流运行。
需要打开配置:
#defineconfigUSE_TIME_SLICING1例如:
任务A 优先级2 任务B 优先级2 任务C 优先级2它们会在相同优先级下轮流获得 CPU。
3. 阻塞让出 CPU
任务如果调用了延时、等待队列、等待信号量等 API,就会进入阻塞态。
例如:
vTaskDelay(pdMS_TO_TICKS(100));任务会暂时让出 CPU,等 100ms 后再进入就绪态。
八、任务的四种典型状态
FreeRTOS 任务常见状态包括:
Running:运行态 Ready:就绪态 Blocked:阻塞态 Suspended:挂起态1. Running 运行态
表示任务正在 CPU 上执行。
单核 MCU 同一时刻只能有一个任务处于 Running 状态。
2. Ready 就绪态
表示任务已经准备好运行,只是在等待调度器分配 CPU。
如果当前没有更高优先级任务运行,就绪态任务可能会被调度执行。
3. Blocked 阻塞态
表示任务正在等待某个事件或时间到达。
常见阻塞原因包括:
调用 vTaskDelay() 等待队列数据 等待信号量 等待互斥锁 等待事件组4. Suspended 挂起态
表示任务被手动挂起,不参与调度。
需要调用:
vTaskResume()才能恢复到就绪态。
图2 FreeRTOS 任务与调度机制分析
任务调度依赖任务优先级、任务状态和 Tick 节拍。高优先级任务就绪时可以抢占低优先级任务,同优先级任务可以进行时间片轮转。
九、任务延时:vTaskDelay 和 vTaskDelayUntil
任务中不能使用长时间死循环延时,否则会浪费 CPU。
FreeRTOS 中推荐使用任务延时 API。
1. vTaskDelay
vTaskDelay()是相对延时。
例如:
vTaskDelay(pdMS_TO_TICKS(1000));表示当前任务阻塞大约 1000ms。
在阻塞期间,CPU 可以去运行其他任务。
2. vTaskDelayUntil
vTaskDelayUntil()是绝对周期延时,适合周期性任务。
示例:
voidSensorTask(void*pvParameters){TickType_t xLastWakeTime;xLastWakeTime=xTaskGetTickCount();while(1){Read_Sensor();vTaskDelayUntil(&xLastWakeTime,pdMS_TO_TICKS(10));}}这个任务会尽量保持每 10ms 执行一次。
区别:
| API | 特点 | 适用场景 |
|---|---|---|
| vTaskDelay | 相对延时 | 普通延时 |
| vTaskDelayUntil | 周期延时 | 周期采样、周期控制 |
十、什么是队列 Queue
队列是 FreeRTOS 中非常常用的任务通信机制。
它主要用于:
任务与任务之间传递数据 中断与任务之间传递数据队列可以理解为一个缓冲区。
例如:
任务A发送数据 → 队列 → 任务B接收数据队列通常遵循 FIFO 原则:
先进先出也就是先写入队列的数据会先被读取出来。
十一、队列常用 API
1. 创建队列
QueueHandle_txQueueCreate(UBaseType_t uxQueueLength,UBaseType_t uxItemSize);参数说明:
uxQueueLength:队列长度,可以存放多少个数据项 uxItemSize:每个数据项的大小,单位为字节示例:
QueueHandle_t SensorQueue;SensorQueue=xQueueCreate(10,sizeof(uint16_t));表示创建一个队列,可以存放 10 个uint16_t类型数据。
2. 发送数据到队列
xQueueSend(SensorQueue,&value,pdMS_TO_TICKS(10));如果队列满了,任务最多等待 10ms。
3. 从队列接收数据
xQueueReceive(SensorQueue,&value,portMAX_DELAY);如果队列为空,任务会一直等待,直到收到数据。
4. 中断中发送队列
在中断服务函数中,需要使用 FromISR 版本:
xQueueSendFromISR(SensorQueue,&value,&xHigherPriorityTaskWoken);十二、队列使用示例
下面是一个生产者和消费者模型。
生产者任务采集传感器数据,然后写入队列。
消费者任务从队列中读取数据并处理。
QueueHandle_t SensorQueue;voidSensorTask(void*pvParameters){uint16_tsensor_value;while(1){sensor_value=Read_ADC();xQueueSend(SensorQueue,&sensor_value,pdMS_TO_TICKS(10));vTaskDelay(pdMS_TO_TICKS(100));}}voidDisplayTask(void*pvParameters){uint16_trecv_value;while(1){if(xQueueReceive(SensorQueue,&recv_value,portMAX_DELAY)==pdPASS){Display_Value(recv_value);}}}这个例子中:
SensorTask 负责产生数据 DisplayTask 负责消费数据 SensorQueue 负责在两个任务之间传递数据图3 FreeRTOS 队列通信机制
队列适合任务之间传递数据,数据会被复制到队列缓冲区中。常用于串口接收、传感器数据上报、按键事件处理等场景。
十三、队列适合传递什么
队列适合传递:
传感器采样值 按键事件 串口数据 命令消息 结构体数据 状态信息例如传递结构体:
typedefstruct{uint8_tid;uint16_tvalue;}SensorMsg_t;QueueHandle_t MsgQueue;MsgQueue=xQueueCreate(10,sizeof(SensorMsg_t));发送:
SensorMsg_t msg;msg.id=1;msg.value=2500;xQueueSend(MsgQueue,&msg,pdMS_TO_TICKS(10));接收:
SensorMsg_t recv_msg;xQueueReceive(MsgQueue,&recv_msg,portMAX_DELAY);十四、队列满和队列空
1. 队列满
当队列已经存满数据时,再调用xQueueSend()会出现以下情况:
如果等待时间为 0:立即返回失败 如果等待时间大于 0:任务进入阻塞等待 如果等待时间为 portMAX_DELAY:一直等待直到有空间2. 队列空
当队列没有数据时,调用xQueueReceive()会出现以下情况:
如果等待时间为 0:立即返回失败 如果等待时间大于 0:任务进入阻塞等待 如果等待时间为 portMAX_DELAY:一直等待直到有数据所以队列不仅能传递数据,还能天然实现任务阻塞等待,避免任务空转浪费 CPU。
十五、什么是信号量 Semaphore
信号量主要用于:
任务同步 事件通知 资源计数它不是主要用来传递大数据的。
可以简单理解为:
信号量传递的是“事件发生了”这个信号。例如:
中断发生 ↓ 释放信号量 ↓ 任务被唤醒 ↓ 任务开始处理数据常见信号量包括:
二值信号量 Binary Semaphore 计数信号量 Counting Semaphore十六、二值信号量
二值信号量只有两个状态:
0:不可用 1:可用它非常适合做事件通知。
例如:
ADC采样完成中断 DMA传输完成中断 串口接收完成中断 按键中断中断中释放信号量,任务中获取信号量并处理事件。
十七、计数信号量
计数信号量可以表示多个资源的数量。
例如:
有3个缓冲区可用 有5个连接资源可用 有N个事件待处理计数信号量内部有一个计数值。
Give:计数值加1 Take:计数值减1 计数为0时,任务获取会阻塞十八、信号量常用 API
1. 创建二值信号量
SemaphoreHandle_txSemaphoreCreateBinary(void);2. 创建计数信号量
SemaphoreHandle_txSemaphoreCreateCounting(UBaseType_t uxMaxCount,UBaseType_t uxInitialCount);3. 释放信号量
xSemaphoreGive(SemaphoreHandle_t xSemaphore);4. 获取信号量
xSemaphoreTake(SemaphoreHandle_t xSemaphore,TickType_t xTicksToWait);5. 中断中释放信号量
xSemaphoreGiveFromISR(SemaphoreHandle_t xSemaphore,BaseType_t*pxHigherPriorityTaskWoken);十九、信号量使用示例:中断通知任务
假设 ADC 转换完成后产生中断,中断中释放信号量,任务中等待信号量并处理 ADC 数据。
SemaphoreHandle_t AdcSemaphore;voidADC_IRQHandler(void){BaseType_t xHigherPriorityTaskWoken=pdFALSE;/* 清除中断标志 */ADC_ClearFlag();xSemaphoreGiveFromISR(AdcSemaphore,&xHigherPriorityTaskWoken);portYIELD_FROM_ISR(xHigherPriorityTaskWoken);}voidAdcProcessTask(void*pvParameters){while(1){if(xSemaphoreTake(AdcSemaphore,portMAX_DELAY)==pdTRUE){Process_ADC_Data();}}}这个例子中:
中断只负责快速通知 任务负责具体处理数据这是一种常见设计思路。
不要在中断中做复杂处理,应该尽量把耗时逻辑放到任务中执行。
图4 FreeRTOS 信号量详解
信号量适合任务同步和事件通知,不适合传递大量数据。中断中释放信号量,任务中获取信号量,是 FreeRTOS 中非常常见的处理方式。
二十、队列和信号量有什么区别
队列和信号量都可以让任务阻塞等待,但它们用途不同。
| 对比项 | 队列 Queue | 信号量 Semaphore |
|---|---|---|
| 主要用途 | 传递数据 | 同步、通知、计数 |
| 是否保存数据 | 保存数据内容 | 不保存数据内容 |
| 传递内容 | 消息、结构体、采样值 | 事件信号、资源数量 |
| 典型操作 | Send / Receive | Give / Take |
| 常见场景 | 串口数据、传感器数据 | 中断通知、资源计数 |
一句话总结:
队列适合传数据,信号量适合同步和通知。二十一、什么是互斥锁 Mutex
互斥锁是一种特殊的信号量,用于保护共享资源。
共享资源指的是多个任务都会访问的资源,例如:
串口打印 printf I2C总线 SPI外设 全局变量 LCD显示屏 文件系统 共享数据结构如果多个任务同时访问共享资源,可能会导致数据混乱。
例如两个任务同时打印串口:
任务A打印:Hello 任务B打印:World如果不加保护,串口输出可能变成:
HeWolrllod因此需要使用互斥锁。
二十二、互斥锁常用 API
创建互斥锁:
SemaphoreHandle_txSemaphoreCreateMutex(void);获取互斥锁:
xSemaphoreTake(MutexHandle,portMAX_DELAY);释放互斥锁:
xSemaphoreGive(MutexHandle);删除互斥锁:
vSemaphoreDelete(MutexHandle);二十三、互斥锁使用示例
多个任务共用串口打印时,可以用互斥锁保护。
SemaphoreHandle_t UartMutex;voidSafe_Printf(constchar*str){if(xSemaphoreTake(UartMutex,portMAX_DELAY)==pdTRUE){printf("%s",str);xSemaphoreGive(UartMutex);}}任务中使用:
voidTaskA(void*pvParameters){while(1){Safe_Printf("TaskA running\r\n");vTaskDelay(pdMS_TO_TICKS(1000));}}voidTaskB(void*pvParameters){while(1){Safe_Printf("TaskB running\r\n");vTaskDelay(pdMS_TO_TICKS(1000));}}这样可以保证同一时刻只有一个任务访问串口打印函数。
二十四、为什么需要互斥锁
如果没有互斥锁,多个任务同时访问共享资源,可能出现:
数据覆盖 通信冲突 总线异常 打印混乱 共享变量被同时修改 设备状态错误例如 I2C 总线:
任务A正在读取温度传感器 任务B同时访问EEPROM如果不加互斥锁,两个任务可能同时操作 I2C,导致总线时序混乱。
正确做法是:
访问I2C前获取互斥锁 访问完成后释放互斥锁二十五、优先级翻转问题
互斥锁中有一个非常重要的问题,叫优先级翻转。
假设有三个任务:
高优先级任务 H 中优先级任务 M 低优先级任务 L执行过程如下:
1. 低优先级任务 L 获取互斥锁 2. 高优先级任务 H 也想获取这个锁,但锁被 L 占用,所以 H 阻塞 3. 中优先级任务 M 就绪并运行 4. L 因为优先级低,迟迟得不到 CPU,无法释放锁 5. H 一直等待 L 释放锁结果就是:
高优先级任务 H 被中优先级任务 M 间接阻塞。这就是优先级翻转。
二十六、优先级继承
为了解决优先级翻转,FreeRTOS 的互斥锁支持优先级继承。
当高优先级任务等待低优先级任务持有的互斥锁时,低优先级任务会临时继承高优先级任务的优先级。
这样低优先级任务可以尽快运行并释放锁。
过程如下:
低优先级任务持有锁 ↓ 高优先级任务等待锁 ↓ 低优先级任务临时提升优先级 ↓ 低优先级任务继续运行并释放锁 ↓ 高优先级任务获得锁 ↓ 低优先级任务恢复原优先级这就是互斥锁和普通二值信号量的重要区别。
图5 FreeRTOS 互斥锁与优先级继承
互斥锁主要用于保护共享资源。与普通信号量不同,互斥锁具有所有权概念,并支持优先级继承,可以缓解优先级翻转问题。
二十七、互斥锁和二值信号量的区别
很多初学者容易把互斥锁和二值信号量混在一起。
它们虽然底层形式相似,但用途不同。
| 对比项 | 互斥锁 Mutex | 二值信号量 Binary Semaphore |
|---|---|---|
| 主要用途 | 保护共享资源 | 任务同步、事件通知 |
| 是否有所有权 | 有,谁获取谁释放 | 无严格所有权 |
| 是否支持优先级继承 | 支持 | 不支持 |
| 能否在中断中使用 | 不建议/不允许 | 可以使用 FromISR 版本 |
| 典型场景 | 串口、I2C、SPI、全局变量 | 中断通知任务、事件同步 |
一句话总结:
共享资源用互斥锁,任务同步用信号量。二十八、队列、信号量、互斥锁怎么选择
可以按照下面思路选择:
1. 需要传递数据
使用队列。
例如:
传递ADC采样值 传递串口接收数据 传递按键事件 传递命令结构体2. 只是通知事件发生
使用二值信号量。
例如:
ADC采样完成 DMA传输完成 串口接收完成 按键中断触发3. 需要表示多个资源数量
使用计数信号量。
例如:
多个缓冲区可用数量 多个连接资源数量 多个事件累计次数4. 需要保护共享资源
使用互斥锁。
例如:
多个任务共用串口 多个任务共用I2C 多个任务访问同一个全局数据 多个任务访问文件系统二十九、FreeRTOS 常见错误
1. 任务优先级设置不合理
如果某个高优先级任务一直不阻塞,就会长期占用 CPU,导致低优先级任务无法运行。
错误写法:
voidHighTask(void*pvParameters){while(1){Do_Something();}}这个任务没有阻塞、没有延时,可能一直占用 CPU。
建议写法:
voidHighTask(void*pvParameters){while(1){Do_Something();vTaskDelay(pdMS_TO_TICKS(10));}}2. 在中断中使用普通 API
中断中不能随便使用普通任务 API。
例如中断中不应该使用:
xSemaphoreGive();xQueueSend();而应该使用 FromISR 版本:
xSemaphoreGiveFromISR();xQueueSendFromISR();3. 互斥锁忘记释放
如果任务获取互斥锁后没有释放,其他任务会一直等待。
错误示例:
xSemaphoreTake(UartMutex,portMAX_DELAY);printf("hello");/* 忘记释放锁 */正确示例:
if(xSemaphoreTake(UartMutex,portMAX_DELAY)==pdTRUE){printf("hello");xSemaphoreGive(UartMutex);}4. 队列数据大小设置错误
创建队列时,uxItemSize必须和发送、接收的数据大小一致。
例如:
QueueHandle_t q;q=xQueueCreate(10,sizeof(uint8_t));如果后面发送uint32_t,就会出错。
正确做法是:
q=xQueueCreate(10,sizeof(uint32_t));5. 任务栈太小
任务栈太小可能导致栈溢出,程序异常、HardFault 或跑飞。
可以开启栈溢出检测:
#defineconfigCHECK_FOR_STACK_OVERFLOW2并实现钩子函数:
voidvApplicationStackOverflowHook(TaskHandle_t xTask,char*pcTaskName){printf("Stack overflow: %s\r\n",pcTaskName);while(1){}}三十、FreeRTOS 调试建议
调试 FreeRTOS 时,可以从以下几个方面入手:
1. 查看任务是否正常创建 2. 查看任务是否进入阻塞态 3. 查看任务优先级是否合理 4. 查看队列是否满或空 5. 查看信号量是否被正确释放 6. 查看互斥锁是否忘记释放 7. 查看任务栈是否溢出 8. 查看中断中是否使用了 FromISR API常用调试工具包括:
串口日志 断点调试 任务运行统计 Tracealyzer Percepio FreeRTOS+Trace如果没有复杂工具,也可以先通过串口打印关键日志:
任务启动 任务进入等待 任务收到队列数据 任务获取信号量 任务获取互斥锁 任务释放互斥锁三十一、核心总结
FreeRTOS 的几个核心概念可以这样记:
任务 Task: 负责执行具体业务逻辑。 调度器 Scheduler: 负责决定哪个任务获得 CPU。 队列 Queue: 用于任务之间传递数据。 信号量 Semaphore: 用于任务同步、事件通知和资源计数。 互斥锁 Mutex: 用于保护共享资源,避免多个任务同时访问导致冲突。再进一步总结:
任务负责干活; 调度器负责分配CPU; 队列负责传数据; 信号量负责通知事件; 互斥锁负责保护资源。最重要的一句话是:
FreeRTOS 不是让多个任务真正同时运行,而是通过调度器让多个任务有序、可控地共享 CPU。在实际项目中,想用好 FreeRTOS,需要重点理解:
任务优先级 任务阻塞 任务状态切换 队列通信 信号量同步 互斥锁保护 中断与任务协作只有把这些机制理解清楚,才能写出稳定、实时、可维护的嵌入式程序。
文章标签
FreeRTOS RTOS 嵌入式开发 STM32 GD32 单片机 任务调度 队列 信号量 互斥锁 操作系统 实时系统 嵌入式学习