时间管理
时间管理
FreeRTOS 的时间管理建立在 Tick(系统节拍) 之上。
内核通过周期性的 SysTick 中断推进系统时间,并根据任务的阻塞时间判断哪些任务应该重新进入就绪态。
从使用角度看,最常见的两个延时 API 是:
vTaskDelay():相对延时vTaskDelayUntil():绝对时间/周期延时
除此之外,FreeRTOS 还通过延时链表、xTickCount、xNextTaskUnblockTime 等机制管理任务的超时和唤醒。
vTaskDelay():相对延时
vTaskDelay() 是一种相对延时。
所谓相对延时,就是:
从任务调用
vTaskDelay()的这一刻开始,向后延迟指定的 Tick 数。
例如:
while(1)
{
task_do_something();
vTaskDelay(pdMS_TO_TICKS(100));
}假设 task_do_something() 执行了 10 ms,那么:
开始执行
│
├── 执行任务 10 ms
│
▼
调用 vTaskDelay(100ms)
│
├── 阻塞 100 ms
│
▼
再次运行因此,两次任务开始运行之间的实际周期大约是:
任务执行时间 + vTaskDelay() 延时时间如果任务本身执行时间发生变化,周期也会随之发生变化。
vTaskDelay() 的核心实现
vTaskDelay() 的核心逻辑可以概括为:
vTaskDelay(xTicksToDelay)
│
├── xTicksToDelay == 0?
│ │
│ └── 是 → 不进入延时状态
│ → 主动让出 CPU
│
└── xTicksToDelay > 0
│
├── 暂停调度器
│
├── 将当前任务加入延时链表
│
├── 恢复调度器
│
└── 如果没有发生任务切换
│
└── 主动触发 PendSV核心源码:
void vTaskDelay( const TickType_t xTicksToDelay )
{
BaseType_t xAlreadyYielded = pdFALSE;
if( xTicksToDelay > ( TickType_t ) 0U )
{
configASSERT( uxSchedulerSuspended == 0 );
/* 暂停调度器 */
vTaskSuspendAll();
{
traceTASK_DELAY();
/* 将当前任务加入延时链表 */
prvAddCurrentTaskToDelayedList(
xTicksToDelay,
pdFALSE
);
}
/* 恢复调度器 */
xAlreadyYielded = xTaskResumeAll();
}
/* 如果恢复调度器时没有发生任务切换,
则主动请求一次任务切换 */
if( xAlreadyYielded == pdFALSE )
{
portYIELD_WITHIN_API();
}
}这里最重要的不是每一行代码,而是理解下面这件事情:
vTaskDelay()本身并不是“让 CPU 原地等待”。
它做的是:
当前任务
│
▼
从 Ready List 移除
│
▼
加入 Delayed List
│
▼
当前任务进入 Blocked 状态
│
▼
其他 Ready 任务运行所以 FreeRTOS 的延时不会像下面这样浪费 CPU:
while(time_not_arrive)
{
;
}而是让当前任务进入阻塞态,把 CPU 让给其他任务。
vTaskDelay(0) 的特殊情况
当:
vTaskDelay(0);时,它并不会真正进入延时链表。
它更接近:
taskYIELD();也就是:
当前任务主动让出 CPU,让调度器重新选择任务。
因此:
vTaskDelay(0);不要理解成“延时 0 ms”。
更准确地说,它是一次主动让出处理器。
延时链表
任务调用 vTaskDelay() 后,并不是简单地保存一个“我要睡眠 100 ms”的变量。
FreeRTOS 会把任务放入延时链表。
内核主要维护两条延时链表:
pxDelayedTaskList
│
└── 当前 Tick 周期内即将到期的任务
pxOverflowDelayedTaskList
│
└── Tick 溢出之后才到期的任务两个链表的存在主要是为了处理:
xTickCount溢出的问题。
为什么需要两个延时链表
假设为了方便理解,我们使用一个 8 位 Tick:
xTickCount:
0 → 1 → 2 → ... → 253 → 254 → 255 → 0 → 1 → ...当 Tick 达到最大值:
255下一次加 1 就会变成:
0这就是 Tick Overflow。
假设当前:
xTickCount = 250现在有两个任务:
任务 A:
delay = 10
任务 B:
delay = 3那么:
A:
wakeTime = 250 + 10
= 260
= 4 (8 位溢出)
B:
wakeTime = 250 + 3
= 253因此:
当前延时链表:
B → wakeTime = 253
溢出延时链表:
A → wakeTime = 4运行过程:
250
│
├── B 等待
│
253
│
└── B 被唤醒
│
254
│
255
│
▼
0 ← Tick 溢出
│
└── 交换两个延时链表
│
1
│
2
│
3
│
4
└── A 被唤醒因此两个链表解决的核心问题就是:
如何正确处理 Tick 计数器从最大值回到 0 后的任务唤醒。
延时链表的工作方式
任务进入延时时,大致计算:
xTimeToWake = xTickCount + xTicksToDelay;然后根据是否跨越 Tick 溢出选择对应的延时链表。
加入延时任务
│
▼
计算唤醒时间
wakeTime = xTickCount + delay
│
├── 未跨越 Tick 溢出
│ │
│ └── pxDelayedTaskList
│
└── 跨越 Tick 溢出
│
└── pxOverflowDelayedTaskList延时链表中的任务按照唤醒时间排序。
因此内核只需要关注:
延时链表头部任务因为它是最早到期的任务。
SysTick 如何唤醒延时任务
SysTick 周期性触发。
例如:
SysTick
│
▼
xPortSysTickHandler()
│
▼
xTaskIncrementTick()xTaskIncrementTick() 首先推进:
xTickCount++;然后检查有没有任务到期。
例如:
当前:
xTickCount = 100
任务 A:
wakeTime = 100那么任务 A 已经到期。
内核就会:
Delayed List
│
▼
移除任务 A
│
▼
加入 Ready List
│
▼
判断是否需要抢占当前任务因此:
任务延时
│
▼
进入 Delayed List
│
│ SysTick
│ │
│ ▼
│ xTaskIncrementTick()
│ │
│ ▼
│ 到达 wakeTime?
│ │
│ ▼
└──────→ 移入 Ready ListvTaskDelayUntil():绝对时间延时
vTaskDelayUntil() 和 vTaskDelay() 最大的区别是:
vTaskDelay()
↓
从“当前调用时刻”开始向后延时
vTaskDelayUntil()
↓
按照“预定的绝对唤醒时刻”运行因此 vTaskDelayUntil() 非常适合周期性任务。
典型代码:
void Task(void *argument)
{
TickType_t previousWakeTime;
previousWakeTime = xTaskGetTickCount();
while(1)
{
task_do_something();
vTaskDelayUntil(
&previousWakeTime,
pdMS_TO_TICKS(100)
);
}
}它的目标是:
100 ms
200 ms
300 ms
400 ms
500 ms
...而不是:
执行10ms → 延时100ms
执行20ms → 延时100ms
执行15ms → 延时100ms为什么 vTaskDelayUntil() 能保持周期稳定
假设任务执行时间为 10 ms。
使用:
vTaskDelay(pdMS_TO_TICKS(100));那么:
0
│
├── 执行 10 ms
│
10
│
├── 延时 100 ms
│
110
│
├── 执行 10 ms
│
120
│
├── 延时 100 ms
│
220因此任务运行周期约为:
110 ms
110 ms
110 ms
...而使用:
vTaskDelayUntil(&previousWakeTime, 100);内核维护的是:
下一次唤醒时间:
100
200
300
400
500
...因此:
0
│
├── 执行 10 ms
│
10
│
├── 等待到 100
│
100
│
├── 执行 10 ms
│
110
│
├── 等待到 200
│
200
│
├── 执行 10 ms
│
210
│
└── 等待到 300任务的周期基准仍然是:
100 ms而不是:
任务执行时间 + 100 msvTaskDelay() 与 vTaskDelayUntil() 对比
| 特性 | vTaskDelay() | vTaskDelayUntil() |
|---|---|---|
| 延时类型 | 相对延时 | 绝对时间/周期延时 |
| 时间基准 | 当前调用时刻 | 上一次预定唤醒时刻 |
| 适合场景 | 普通延时 | 周期性任务 |
| 任务执行时间是否影响周期 | 会 | 正常情况下不会累积 |
| 是否需要保存时间变量 | 不需要 | 需要 |
| 常见应用 | 等待一段时间 | 传感器周期采样、控制任务 |
例如:
vTaskDelay(pdMS_TO_TICKS(100));更适合:
做完 → 等 100ms → 再做而:
vTaskDelayUntil(
&previousWakeTime,
pdMS_TO_TICKS(100)
);更适合:
每隔 100ms 执行一次vTaskDelayUntil() 的核心逻辑
它的核心思想其实非常简单:
保存上一次预定唤醒时间
│
▼
下一次唤醒时间 =
上一次唤醒时间 + 周期
│
▼
当前时间是否已经到达?
│
├── 是 → 不需要继续阻塞
│
└── 否 → 加入延时链表因此:
xTimeToWake = *pxPreviousWakeTime + xTimeIncrement;
*pxPreviousWakeTime = xTimeToWake;这个变量:
pxPreviousWakeTime非常关键。
它保存的不是:
“我上一次什么时候真正开始运行”。
而是:
“我上一次计划在哪个 Tick 唤醒”。
这也是 vTaskDelayUntil() 能够保持周期性的关键。
vTaskDelayUntil() 的一个重要特性
假设周期:
100 ms但是某一次任务执行时间过长:
计划:
100
200
300
400实际执行:
100
230
300
400也就是说,如果任务某一次运行超过了自己的周期,vTaskDelayUntil() 不会简单地再延时一个完整周期。
它仍然按照原来的绝对时间基准继续计算。
因此它更适合:
周期采样
周期控制
周期通信
周期状态检测这也是它和 vTaskDelay() 最重要的区别之一。
任务延时与任务状态
任务调用:
vTaskDelay(100);之后,任务并不是继续占用 CPU。
状态变化大致为:
Running
│
│ vTaskDelay()
▼
Blocked
│
│ Tick 到达唤醒时间
▼
Ready
│
│ 调度器选择
▼
Running所以:
Running
↓
Delayed / Blocked
↓
Ready
↓
Running这是理解 FreeRTOS 时间管理最重要的一条状态链。
调度器挂起:vTaskSuspendAll()
在前面的 vTaskDelay() 和 vTaskDelayUntil() 中都出现了:
vTaskSuspendAll();它的作用是:
暂时禁止 FreeRTOS 进行任务调度。
注意:
vTaskSuspendAll() ≠ 关闭中断。
这是一个非常容易混淆的地方。
调用:
vTaskSuspendAll();后:
任务调度:
暂停
普通硬件中断:
仍然可以发生
CPU:
仍然可以响应允许的中断因此不能写成:
vTaskSuspendAll()
↓
关闭所有中断这是错误的。
uxSchedulerSuspended
FreeRTOS 使用:
uxSchedulerSuspended记录调度器挂起的嵌套层数。
调用:
vTaskSuspendAll();相当于:
uxSchedulerSuspended++;调用:
xTaskResumeAll();相当于:
uxSchedulerSuspended--;只有当:
uxSchedulerSuspended == 0时,调度器才真正恢复。
因此它支持嵌套:
vTaskSuspendAll()
uxSchedulerSuspended = 1
vTaskSuspendAll()
uxSchedulerSuspended = 2
xTaskResumeAll()
uxSchedulerSuspended = 1
xTaskResumeAll()
uxSchedulerSuspended = 0
↓
调度器真正恢复xTaskResumeAll()
恢复调度器的主要工作可以理解为:
xTaskResumeAll()
│
├── uxSchedulerSuspended--
│
└── 是否恢复到 0?
│
├── 否 → 继续保持挂起
│
└── 是
│
├── 处理挂起期间变为 Ready 的任务
│
├── 处理挂起期间累积的 Tick
│
├── 判断是否需要任务切换
│
└── 必要时触发调度xPendingReadyList
这里有一个非常重要的机制。
当调度器被挂起时:
uxSchedulerSuspended != 0某些任务可能由于 ISR 的操作而变成 Ready。
但是此时调度器不能直接进行正常的就绪链表操作。
因此 FreeRTOS 会使用:
xPendingReadyList暂存这些任务。
可以理解为:
调度器挂起
│
│ ISR 使任务解除阻塞
▼
xPendingReadyList
│
│ xTaskResumeAll()
▼
Ready List调度器恢复后,再把这些任务正式放入对应优先级的 Ready List。
uxPendedTicks
如果调度器处于挂起状态,Tick 中断仍然可能发生。
这时候不能正常进行完整的调度处理。
因此 FreeRTOS 会记录:
uxPendedTicks++;意思是:
这个 Tick 已经发生了,但是因为调度器当前被挂起,所以先记下来,等调度器恢复之后再补处理。
因此:
SysTick
│
▼
xTaskIncrementTick()
│
▼
调度器挂起?
│
├── 否 → 正常处理 Tick
│
└── 是 → uxPendedTicks++之后:
xTaskResumeAll()
│
▼
处理 uxPendedTicks
│
▼
重新调用 xTaskIncrementTick()
│
▼
补处理这些 TickxTaskIncrementTick()
xTaskIncrementTick() 是 FreeRTOS 时间管理中非常重要的函数。
它主要负责:
- 增加
xTickCount - 处理 Tick 溢出
- 检查延时任务是否到期
- 将到期任务从阻塞态移到就绪态
- 判断是否需要抢占
- 处理同优先级任务的时间片轮转
- 返回是否需要进行任务切换
整体过程可以理解为:
SysTick
│
▼
xTaskIncrementTick()
│
├── xTickCount++
│
├── Tick 是否溢出?
│ └── 是 → 交换两个延时链表
│
├── 是否有任务到期?
│ └── 是 → Blocked → Ready
│
├── 是否有更高优先级任务就绪?
│ └── 是 → 请求切换
│
├── 是否需要时间片轮转?
│ └── 是 → 请求切换
│
└── 返回 xSwitchRequired任务超时解阻
假设:
当前 Tick = 100任务 A:
wakeTime = 105那么:
100 → 101 → 102 → 103 → 104 → 105当:
xTickCount = 105任务 A 到期。
内核执行:
Delayed List
│
▼
找到任务 A
│
▼
从 Delayed List 移除
│
▼
从 Event List 移除(如果任务同时等待某个事件)
│
▼
加入 Ready List此时任务 A 的状态变成:
Blocked → Ready到期任务是否立即运行
这里非常重要:
任务从 Blocked 变成 Ready,并不意味着它立刻执行。
例如:
当前任务:
Priority = 3
刚刚唤醒的任务:
Priority = 2那么:
任务 A:
Blocked → Ready
但是当前任务优先级更高
↓
当前任务继续运行如果:
当前任务:
Priority = 2
刚刚唤醒的任务:
Priority = 5那么:
Blocked → Ready
↓
发现优先级更高
↓
xSwitchRequired = pdTRUE
↓
请求 PendSV
↓
任务切换因此:
任务到期
↓
Blocked → Ready
↓
是否需要抢占?
↓
是
↓
PendSV
↓
上下文切换SysTick 与 PendSV 的关系
这是整个时间管理中最重要的关系之一。
很多初学者容易理解成:
SysTick
↓
直接切换任务实际上更准确的是:
SysTick
│
▼
xTaskIncrementTick()
│
▼
判断是否需要切换
│
├── 不需要 → 返回
│
└── 需要
│
▼
设置 PendSV Pending
│
▼
PendSV 异常
│
▼
真正执行上下文切换所以:
SysTick 负责“时间到了、要不要切换”的判断;PendSV 负责真正的上下文切换。
SysTick 的典型执行流程
void SysTick_Handler(void)
{
if(xTaskGetSchedulerState() != taskSCHEDULER_NOT_STARTED)
{
xPortSysTickHandler();
}
HAL_IncTick();
}其中:
xPortSysTickHandler();进一步调用:
xTaskIncrementTick();如果返回:
pdTRUE则说明:
当前 Tick 处理后,需要进行一次任务切换。
于是:
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET;设置 ICSR 的:
PENDSVSET位。
时间管理的完整链路
把前面的内容串起来,可以得到 FreeRTOS 时间管理的完整流程:
SysTick 定时到期
│
▼
SysTick_Handler()
│
▼
xPortSysTickHandler()
│
▼
xTaskIncrementTick()
│
┌────────┴────────┐
│ │
▼ ▼
xTickCount++ 检查延时任务
│
▼
是否有任务到期?
│ │
否 是
│ │
│ ▼
│ Blocked → Ready
│ │
│ ▼
│ 是否需要抢占?
│ │
└────┬─────┘
│
需要任务切换
│
▼
设置 PendSV Pending
│
▼
PendSV 异常
│
▼
保存当前任务上下文
│
▼
vTaskSwitchContext()
│
▼
选择下一个任务
│
▼
恢复新任务上下文
│
▼
新任务运行vTaskDelay() 的完整链路
任务运行
│
▼
vTaskDelay(100)
│
▼
计算唤醒时间
│
▼
当前任务加入 Delayed List
│
▼
当前任务进入 Blocked
│
▼
请求任务切换
│
▼
PendSV
│
▼
其他任务运行
│
│
│ SysTick 不断到来
▼
xTickCount++
│
▼
达到 wakeTime
│
▼
任务移入 Ready List
│
▼
如果需要抢占
│
▼
PendSV
│
▼
恢复该任务
│
▼
从 vTaskDelay() 后面继续执行这也解释了一个非常重要的问题:
vTaskDelay()函数虽然“返回”了,但任务真正继续执行是在它从 Blocked 状态重新变成 Running 之后。
vTaskDelayUntil() 的完整链路
任务第一次运行
│
▼
记录 previousWakeTime
│
▼
执行任务主体
│
▼
计算:
previousWakeTime + period
│
▼
得到下一次绝对唤醒时间
│
▼
进入 Delayed List
│
▼
Blocked
│
│ SysTick
▼
到达指定唤醒时间
│
▼
Blocked → Ready
│
▼
重新运行任务
│
▼
继续执行任务主体
│
▼
计算下一个周期所以 vTaskDelayUntil() 本质上是在维护:
T0
T0 + P
T0 + 2P
T0 + 3P
T0 + 4P
...其中:
T0 = 初始唤醒时间
P = 周期调度器挂起、临界区与中断屏蔽的区别
这三个概念一定要区分开。
调度器挂起
vTaskSuspendAll();作用:
禁止 FreeRTOS 进行任务调度但:
硬件中断仍然可以发生临界区
例如:
taskENTER_CRITICAL();在 Cortex-M 的 FreeRTOS 移植中,通常会使用:
PRIMASK或:
BASEPRI等机制屏蔽中断。
具体屏蔽哪些中断取决于具体 Cortex-M 内核和 FreeRTOS port 的实现。
因此:
调度器挂起
≠
关闭中断而:
进入临界区
→ 屏蔽一定范围的中断为什么 vTaskSuspendAll() 还允许中断发生
这是因为 FreeRTOS 希望:
在不进行任务切换的同时,尽量减少对硬件中断实时性的影响。
例如:
任务:
vTaskSuspendAll()
│
│
├───────────────┐
│ │
▼ ▼
继续执行任务 UART中断发生
│
▼
ISR执行
│
▼
ISR返回
│
▼
xTaskResumeAll()因此:
vTaskSuspendAll();不能理解成:
“CPU 什么中断都不处理了。”
它只是:
“FreeRTOS 暂时不进行任务调度。”
最终需要记住的几个核心关系
如果把这一章压缩成几句话,最重要的是下面这些:
vTaskDelay()
↓
相对延时
↓
从当前调用时刻开始计算
vTaskDelayUntil()
↓
绝对时间/周期延时
↓
按照预定唤醒时间运行
SysTick
↓
推进 xTickCount
↓
检查任务是否到期
↓
判断是否需要任务切换
PendSV
↓
真正执行上下文切换
vTaskSuspendAll()
↓
暂停任务调度
↓
不等于关闭中断
taskENTER_CRITICAL()
↓
进入临界区
↓
根据 Cortex-M port 实现屏蔽一定范围的中断整个 FreeRTOS 的时间管理可以最终记成:
时间管理
│
┌───────┴────────┐
│ │
vTaskDelay() vTaskDelayUntil()
│ │
相对时间 绝对时间
│ │
└───────┬────────┘
▼
Delayed List
│
│ SysTick
▼
xTaskIncrementTick()
│
▼
Blocked → Ready
│
▼
是否需要切换?
│
▼
PendSV
│
▼
上下文切换
│
▼
新任务运行这条链路基本就是理解 FreeRTOS “时间 → 延时 → 唤醒 → 调度 → 任务切换” 的主线。

