FreeRTOS时间片调度及代码实践
前言
上一篇文章《硬核解析FreeRTOS任务切换:从寄存器底层到PendSV机制,一文吃透核心原理》介绍了不同优先级的任务之间如何切换,本质上是“抢占式调度”。那么问题来了,相同优先级且都处于就绪态的任务如何进行切换呢?
一、时间片调度的原理
FreeRTOS中,时间片调度是多任务调度机制中的一种重要方式,它允许具有相同优先级的任务轮流执行,确保每个任务都有机会使用 CPU 资源。
1. 时间片调度的基本概念
时间片,也称为时间配额,是指为每个任务分配的一段固定的 CPU 执行时间。在 FreeRTOS 中,当多个任务具有相同优先级时,系统会按照时间片轮转的方式,让这些任务依次运行一个时间片的时长,然后切换到下一个任务,从而实现多个同优先级任务的并发执行效果,从宏观上看,就好像这些任务在同时运行。
2. 时间片调度的实现原理
- SysTick 定时器:FreeRTOS 使用 SysTick(系统滴答定时器)作为系统的时基。通常会配置 SysTick 定时器产生周期性的中断,比如每 1ms 产生一次中断。每次 SysTick 中断发生时,系统会执行中断服务函数,在这个函数中对系统时间进行更新,并检查是否需要进行任务调度。
- 任务切换机制:当 SysTick 中断触发任务调度时,如果当前正在运行的任务的时间片已经用完(通过一个计数器记录任务已执行的时间片数量来判断),并且存在其他同优先级的就绪任务,调度器就会触发上下文切换,保存当前任务的现场(将 CPU 寄存器的值保存到任务的堆栈中),并恢复下一个同优先级就绪任务的现场(从该任务的堆栈中恢复 CPU 寄存器的值到 CPU 中),让下一个任务开始执行。
- 就绪任务列表:FreeRTOS 维护了多个就绪任务列表,按照任务优先级进行组织。对于每个优先级,都有一个对应的列表,同优先级的任务都被添加到该优先级对应的列表中。当进行时间片调度时,调度器会从当前优先级的就绪任务列表中选择下一个要执行的任务,通常是按照先进先出(FIFO)的顺序选取。
3. 四个问题
1. 时间片的长度谁决定?
宏定义 configTICK_RATE_HZ
!
在 FreeRTOS 的配置文件 FreeRTOSConfig.h
中,configTICK_RATE_HZ
用于设置 SysTick 定时器的中断频率,即系统时基的频率。例如,若将其设置为 1000,就表示每 1ms 产生一次 SysTick 中断。这个值的大小会影响时间片的精度和系统的整体性能。
例如:
#define configTICK_RATE_HZ ( ( TickType_t ) 1000 )
2. FreeRTOS 维护了多少个就绪任务列表?
我们先看代码中是怎么说的。下面定义了一个数组,就是就绪状态的列表数组。所以数组中元素的个数,就是就绪列表的个数。
PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];/*< Prioritised ready tasks. */
查看这个宏定义,发现我当前代码中,这个宏等于5。
#define configMAX_PRIORITIES ( 5 )
3. 这意味着什么?
紧接着上面的问题,宏定义configMAX_PRIORITIES
等于5
,意味着我的任务优先级必须是0-4
!在任务创建的时候,我们需要指定任务的优先级,这个时候就需要关注一下:我的任务最多可以设置多大的优先级。如果设置的优先级超过了这个范围,FreeRTOS会自动设置为最大优先级。具体操作如下:
if( uxPriority >= ( UBaseType_t ) configMAX_PRIORITIES )
{
uxPriority = ( UBaseType_t ) configMAX_PRIORITIES - ( UBaseType_t ) 1U;
}
代码里的宏上面已经说了,就不赘述了。
4. 使能时间片调度的宏有哪些?
宏定义
configUSE_TIME_SLICING
必须使能!该配置项用于开启或关闭时间片调度功能。- 将其设置为
1
表示启用
时间片调度,当有多个同优先级任务就绪时,会按照时间片轮转的方式进行调度。 - 设置为
0
则表示禁止
时间片调度,同优先级的任务会按照其他方式(如先到先服务,直到任务阻塞或主动让出 CPU )执行。
- 将其设置为
另外,宏
configUSE_PREEMPTION
也必须使能!否则无法完成时间片调度。这部分内容在二、实践测试
章节会细讲。
4. 时间片调度的优缺点
- 优点:
- 公平性:确保了同优先级的任务都能公平地获得 CPU 执行时间,不会出现某个任务长时间占用 CPU 而导致其他同优先级任务得不到执行的情况。
- 提高响应性:对于一些对实时性要求不是特别高,但又需要并发执行的任务,时间片调度可以在一定程度上提高系统对这些任务的响应性,使它们看起来像是在同时运行。
- 缺点:
- 上下文切换开销:每次时间片切换都需要进行上下文切换操作,保存和恢复任务的现场,这会带来一定的 CPU 开销。如果时间片设置得过短,频繁的上下文切换可能会降低系统的整体性能。
- 无法满足严格实时性需求:对于一些对时间要求极其严格的实时任务,时间片调度可能无法保证它们在特定的时间内得到执行,因为任务的执行顺序和时间取决于时间片的轮转,存在一定的不确定性。
5. 时间片调度的应用场景
- 用户接口任务:在一些嵌入式设备中,可能存在多个用户接口相关的任务,如按键处理任务、显示更新任务等,这些任务优先级相同且对实时性要求不是非常苛刻,适合采用时间片调度,使它们能够轮流执行,保证用户操作的流畅响应。
- 数据采集与处理任务:当系统中有多个数据采集传感器,且对应的数据处理任务优先级相同时,可以使用时间片调度,让这些任务依次获取 CPU 资源进行数据处理,实现并发采集和处理的效果。
二、实践测试
下面我们实操测试一下,深入理解一下时间片调度的逻辑。
1. 使能相关的宏定义
#define configUSE_TIME_SLICING 1 //FreeRTOS.h
#define configUSE_PREEMPTION 1 //FreeRTSOConfig.h
#define configTICK_RATE_HZ ( ( TickType_t ) 20 )
注意:这里强行将滴答定时器的中断溢出频率改为20Hz,也就是说,时间片的长度是50ms!等测试完成要改回到1000。
#define configTICK_RATE_HZ ( ( TickType_t ) 20 )
对于宏configTICK_RATE_HZ
已经在上面讲过了,下面讲一下宏configUSE_PREEMPTION
的含义。
1. configUSE_PREEMPTION
在 FreeRTOS 中,configUSE_PREEMPTION
是一个核心的配置宏(定义在 FreeRTOSConfig.h
文件中),用于控制任务调度的基本行为,决定系统是采用 抢占式调度(Preemptive) 还是 协作式调度(Cooperative)。以下是它的详细作用:
(1) 功能说明
启用抢占式调度(
configUSE_PREEMPTION = 1
)- 行为:
- 高优先级任务可以立即抢占正在运行的低优先级任务(例如通过中断、信号量唤醒时)。
- 调度器会在每个时钟节拍(tick)中断时检查是否有更高优先级的任务就绪,若有则触发任务切换。
- 特点:
- 实时性强,适合需要快速响应的场景(如硬件控制、实时数据处理)。
- 默认情况下,FreeRTOS 使用抢占式调度。
- 行为:
禁用抢占式调度(
configUSE_PREEMPTION = 0
,协作式)- 行为:
- 任务必须主动释放 CPU(通过调用
taskYIELD()
、vTaskDelay()
或阻塞式 API 如队列/信号量等待),其他任务才能运行。 - 即使高优先级任务就绪,也不会抢占当前任务。
- 任务必须主动释放 CPU(通过调用
- 特点:
- 任务切换由开发者显式控制,确定性高,但实时性较差。
- 适合资源受限或对任务执行顺序有严格要求的场景。
- 行为:
(2) 与configUSE_TIME_SLICING的关联
当 configUSE_PREEMPTION=1
时,同优先级任务会按时间片轮转调度(需 configUSE_TIME_SLICING=1
)。
注意:若禁用抢占,时间片轮转无效。
2. configUSE_TIME_SLICING
在 FreeRTOS 中,configUSE_TIME_SLICING
是一个配置宏(定义在 FreeRTOSConfig.h
文件中),用于控制 同优先级任务的时间片轮转调度(Round-Robin Scheduling) 行为。时间片轮转 仅在同优先级任务间生效,高优先级任务始终优先执行。
(1) 功能说明
- 当多个任务处于 相同优先级 时,
configUSE_TIME_SLICING
决定是否允许它们 共享 CPU 时间,按照 固定时间片(Time Slice) 轮流执行。 - 默认情况下,FreeRTOS 启用时间片轮转(
configUSE_TIME_SLICING=1
),但仅在 抢占式调度(configUSE_PREEMPTION=1
) 时生效。
(2) 与taskYIELD()的关系
即使禁用时间片轮转,仍可通过taskYIELD()
手动触发任务切换。
configUSE_TIME_SLICING |
效果 |
---|---|
=1 (默认) |
同优先级任务按时间片轮转(每个任务运行一个 tick 周期后切换)。 |
=0 |
同优先级任务 不会自动切换,必须 主动让出 CPU(如调用 taskYIELD() 或阻塞)。 |
2. 配置任务和初始化函数
注意:每个任务的优先级设置成一样的!否则不能测试时间片调度!
#define START_TASK_PRIO 1//任务优先级
#define START_STK_SIZE 128//任务堆栈大小
TaskHandle_t StartTask_Handler;//任务句柄
void start_task(void * pvParameters);//任务函数
#define TASK1_PRIO 3//任务优先级
#define TASK1_STK_SIZE 50//任务堆栈大小
TaskHandle_t Task1_Handler;//任务句柄
void task1(void * p_arg);//任务函数
#define TASK2_PRIO 3//任务优先级
#define TASK2_STK_SIZE 50//任务堆栈大小
TaskHandle_t Task2_Handler;//任务句柄
void task2(void * p_arg);//任务函数
#define TASK3_PRIO 3//任务优先级
#define TASK3_STK_SIZE 50//任务堆栈大小
TaskHandle_t Task3_Handler;//任务句柄
void task3(void * p_arg); //任务函数
int main(void)
{
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);
delay_init(168);
uart_init(115200);
LED_Init();
KEY_Init(); //初始化按键
//创建开始任务
xTaskCreate((TaskFunction_t) start_task,
(const char*) "start_task",
(uint16_t) START_STK_SIZE,
(void*) NULL,
(UBaseType_t)START_TASK_PRIO,
(TaskHandle_t*) &StartTask_Handler);
vTaskStartScheduler();
return 1;
}
//开始任务函数
void start_task(void * pxParameters)
{
taskENTER_CRITICAL();//进入临界区
//创建task1任务
xTaskCreate((TaskFunction_t) task1,
(const char*) "task1",
(uint16_t) TASK1_STK_SIZE,
(void*) NULL,
(UBaseType_t)TASK1_PRIO,
(TaskHandle_t*) &Task1_Handler);
//创建task2任务
xTaskCreate((TaskFunction_t) task2,
(const char*) "task2",
(uint16_t) TASK2_STK_SIZE,
(void*) NULL,
(UBaseType_t)TASK2_PRIO,
(TaskHandle_t*) &Task2_Handler);
//创建task3任务
xTaskCreate((TaskFunction_t) task3,
(const char*) "task3",
(uint16_t) TASK3_STK_SIZE,
(void*) NULL,
(UBaseType_t)TASK3_PRIO,
(TaskHandle_t*) &Task3_Handler);
vTaskDelete(StartTask_Handler);//删除开始任务
taskEXIT_CRITICAL();//推出临界区
}
/*********************************************************************
* task1 时间片是50ms,所以每10ms执行一次,理论上输出5次,但由于printf消耗时间,所以可能是4-5次
**********************************************************************/
void task1(void * pxParameters)
{
uint8_t number_task1 = 0;
while(1)
{
taskENTER_CRITICAL();//进入临界区
printf("Task1 running times: %d\r\n", ++number_task1);
taskEXIT_CRITICAL();//推出临界区
delay_ms(10);
}
}
/*********************************************************************
* task2 时间片是50ms,所以每10ms执行一次,理论上输出5次,但由于printf消耗时间,所以可能是4-5次
**********************************************************************/
void task2(void * pxParameters)
{
uint8_t number_task2 = 0;
while(1)
{
taskENTER_CRITICAL();//进入临界区
printf("Task2 running times: %d\r\n", ++number_task2);
taskEXIT_CRITICAL();//推出临界区
delay_ms(10);
}
}
/*********************************************************************
* task3 时间片是50ms,所以每10ms执行一次,理论上输出5次,但由于printf消耗时间,所以可能是4-5次
**********************************************************************/
void task3(void * pxParameters)
{
uint8_t number_task3 = 0;
while(1)
{
taskENTER_CRITICAL();//进入临界区
printf("Task3 running times: %d\r\n", ++number_task3);
taskEXIT_CRITICAL();//推出临界区
delay_ms(10);
}
}
在上面的代码中,我的每一个任务优先级都是3
,符合前面说的0-4
的范围。另外每个任务做的事也也一样,就是打印出来一个字符串,并且使用非阻塞延时函数延时了10ms。
按理说,在一个时间片,应该执行5次才对,但printf函数本身也要花时间,所以实际上执行的次数是4次。
为了防止打印期间被调度,造成打印出来的数据混乱,我加了临界区保护。如果有亲们对临界区有疑问,可以看这篇文章《FreeRTOS中断屏蔽终极指南:从BASEPRI到临界段,破解99%开发者踩过的寄存器陷阱!》。
3. 实验现象
实验现象与我们预期一致。
参考文献
《正点原子 FreeRTOS开发手册》
本文结束,如果本文帮助到了您,欢迎点赞、收藏、转发。点关注,不迷路,关注后第一时间可以看到我更新的文章哦!