FreeRTOS任务调度详解任务、队列、信号量和互斥锁一、前言在裸机开发中程序通常是按照顺序执行的。例如初始化外设 ↓ while(1)循环 ↓ 不断轮询按键、串口、传感器、电机状态这种方式在功能简单时没有问题但当系统功能越来越多时就会变得很难维护。比如一个嵌入式设备同时需要完成以下任务采集传感器数据 处理串口通信 刷新显示屏 控制电机运行 保存数据到Flash 处理按键输入 上传数据到服务器如果全部放在一个while(1)中轮询就会出现很多问题程序结构混乱 某个功能阻塞后影响其他功能 任务优先级不好控制 实时性不好保证 代码维护难度大这时候就可以引入 RTOS也就是实时操作系统。FreeRTOS 是嵌入式开发中非常常见的轻量级实时操作系统它可以把不同功能拆分成多个任务让调度器根据优先级、阻塞状态和时间片机制来决定哪个任务获得 CPU 执行权。本文主要介绍 FreeRTOS 中几个最核心的知识点任务 Task 任务调度 Scheduler 任务状态切换 队列 Queue 信号量 Semaphore 互斥锁 Mutex 优先级翻转和优先级继承二、什么是 FreeRTOSFreeRTOS 是一个轻量级、开源、可移植的实时操作系统内核主要用于单片机和嵌入式系统。它的特点包括体积小 可裁剪 实时性强 移植方便 适合资源受限的 MCU在 FreeRTOS 中开发者可以把一个复杂系统拆分成多个任务。例如任务1LED闪烁 任务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指针等现场信息五、任务创建函数 xTaskCreateFreeRTOS 中常用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. vTaskDelayvTaskDelay()是相对延时。例如vTaskDelay(pdMS_TO_TICKS(1000));表示当前任务阻塞大约 1000ms。在阻塞期间CPU 可以去运行其他任务。2. vTaskDelayUntilvTaskDelayUntil()是绝对周期延时适合周期性任务。示例voidSensorTask(void*pvParameters){TickType_t xLastWakeTime;xLastWakeTimexTaskGetTickCount();while(1){Read_Sensor();vTaskDelayUntil(xLastWakeTime,pdMS_TO_TICKS(10));}}这个任务会尽量保持每 10ms 执行一次。区别API特点适用场景vTaskDelay相对延时普通延时vTaskDelayUntil周期延时周期采样、周期控制十、什么是队列 Queue队列是 FreeRTOS 中非常常用的任务通信机制。它主要用于任务与任务之间传递数据 中断与任务之间传递数据队列可以理解为一个缓冲区。例如任务A发送数据 → 队列 → 任务B接收数据队列通常遵循 FIFO 原则先进先出也就是先写入队列的数据会先被读取出来。十一、队列常用 API1. 创建队列QueueHandle_txQueueCreate(UBaseType_t uxQueueLength,UBaseType_t uxItemSize);参数说明uxQueueLength队列长度可以存放多少个数据项 uxItemSize每个数据项的大小单位为字节示例QueueHandle_t SensorQueue;SensorQueuexQueueCreate(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_valueRead_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;MsgQueuexQueueCreate(10,sizeof(SensorMsg_t));发送SensorMsg_t msg;msg.id1;msg.value2500;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时任务获取会阻塞十八、信号量常用 API1. 创建二值信号量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 xHigherPriorityTaskWokenpdFALSE;/* 清除中断标志 */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 / ReceiveGive / 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;qxQueueCreate(10,sizeof(uint8_t));如果后面发送uint32_t就会出错。正确做法是qxQueueCreate(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 FreeRTOSTrace如果没有复杂工具也可以先通过串口打印关键日志任务启动 任务进入等待 任务收到队列数据 任务获取信号量 任务获取互斥锁 任务释放互斥锁三十一、核心总结FreeRTOS 的几个核心概念可以这样记任务 Task 负责执行具体业务逻辑。 调度器 Scheduler 负责决定哪个任务获得 CPU。 队列 Queue 用于任务之间传递数据。 信号量 Semaphore 用于任务同步、事件通知和资源计数。 互斥锁 Mutex 用于保护共享资源避免多个任务同时访问导致冲突。再进一步总结任务负责干活 调度器负责分配CPU 队列负责传数据 信号量负责通知事件 互斥锁负责保护资源。最重要的一句话是FreeRTOS 不是让多个任务真正同时运行而是通过调度器让多个任务有序、可控地共享 CPU。在实际项目中想用好 FreeRTOS需要重点理解任务优先级 任务阻塞 任务状态切换 队列通信 信号量同步 互斥锁保护 中断与任务协作只有把这些机制理解清楚才能写出稳定、实时、可维护的嵌入式程序。文章标签FreeRTOS RTOS 嵌入式开发 STM32 GD32 单片机 任务调度 队列 信号量 互斥锁 操作系统 实时系统 嵌入式学习