FreeRTOS软件定时器
FreeRTOS 软件定时器适合对精度要求不高、但又需要周期触发或延时触发的场景。它不是硬件定时器,不会占用某个 TIM 外设,也不会在硬件中断里直接执行回调。软件定时器依赖 FreeRTOS 的 Tick 和一个专门的定时器服务任务。
如果要做 PWM、输入捕获、精确采样这类强实时功能,还是应该用硬件定时器。如果只是 500ms 翻转一次 LED、3s 后超时处理、周期性检查状态,用软件定时器更方便。
一、软件定时器的运行机制
软件定时器启用后,FreeRTOS 内核会创建两个核心对象:
定时器服务任务:也叫 Timer Service Task 或 Daemon Task。它本身也是一个普通 FreeRTOS 任务,由调度器调度运行。
定时器命令队列:任务或 ISR 调用
xTimerStart()、xTimerStop()、xTimerReset()等函数时,并不是直接操作定时器链表,而是把命令发送到这个队列里,由定时器服务任务取出后执行。
因此,软件定时器的操作路径大致是:
1 | 用户任务/ISR 调用 xTimerStart() |
这里有两个关键点。
第一,定时器回调函数不是在中断里运行,而是在定时器服务任务上下文里运行。所以回调函数里可以调用一部分 FreeRTOS 任务级 API,但不能做会长时间阻塞的操作。
第二,所有软件定时器共享同一个定时器服务任务。某一个定时器回调执行太久,会影响其他软件定时器的回调执行,也会影响后续定时器命令的处理。
二、回调函数不能阻塞
定时器到期后执行的是用户注册的回调函数:
1 | static void LedTimerCallback(TimerHandle_t xTimer) |
这个函数运行在定时器服务任务里。回调里可以做很短的状态更新、置标志、发送通知、发送队列消息,但不要调用 vTaskDelay(),也不要调用可能等待很久的队列、信号量、互斥量函数。
比如下面这种写法不合适:
1 | static void BadTimerCallback(TimerHandle_t xTimer) |
原因不是“回调函数不能调用任何 FreeRTOS API”,而是定时器服务任务被这个回调卡住后,其他软件定时器的回调和命令队列都会被拖住。更合适的做法是回调里只通知业务任务:
1 | static TaskHandle_t workTaskHandle; |
回调负责“触发”,任务负责“耗时处理”。这是软件定时器比较稳的用法。
三、CubeMX 和 FreeRTOS 配置
CubeMX 里软件定时器相关配置通常对应 FreeRTOSConfig.h 里的几个宏。不同 CubeMX 版本显示名称可能略有差别,但含义基本一致。
configUSE_TIMERS/USE_TIMERS
这个宏用于启用软件定时器功能。设为 1 后,FreeRTOS 会创建定时器服务任务和定时器命令队列。如果工程里用了事件组的 xEventGroupSetBitsFromISR(),或者用了软件定时器,一般都需要启用它。
CubeMX 生成工程时,有些配置启用后会灰掉,不一定允许在界面里直接关闭。这种情况下以生成的 FreeRTOSConfig.h 为准。
configTIMER_TASK_PRIORITY/TIMER_TASK_PRIORITY
这个宏设置定时器服务任务的优先级。定时器服务任务优先级越高,定时器命令和回调越容易及时处理;优先级太低时,如果系统里一直有更高优先级任务运行,定时器回调就会延迟。
它仍然是任务优先级,不是中断优先级。它不会比硬件中断更高,也不会打断正在执行的 ISR。
配置时可以按这个思路判断:
如果软件定时器只是做普通状态检查,优先级可以不高。
如果软件定时器用于通信超时、控制状态机推进、事件组 FromISR 的延后处理,就不要把它设得太低。
如果回调里做的事比较多,优先级设高只会让它更容易抢占其他任务,不会解决回调本身耗时的问题。
configTIMER_QUEUE_LENGTH/TIMER_QUEUE_LENGTH
这个宏设置定时器命令队列长度。启动、停止、复位、改周期、删除定时器等操作都会发送命令到这个队列里。
如果很多任务或 ISR 在短时间内频繁操作软件定时器,队列太短就可能导致命令发送失败,相关 API 返回 pdFAIL。这时不是定时器本身坏了,而是命令没有成功进入队列。
小工程里 5-10 往往够用;如果软件定时器多,或者事件组 FromISR 使用频繁,可以适当增大。具体还是看项目里同时投递命令的峰值。
configTIMER_TASK_STACK_DEPTH/TIMER_TASK_STACK_DEPTH
这个宏设置定时器服务任务栈大小,单位通常是 word,不是 byte。STM32F4 这种 32 位平台上,一个 word 一般是 4 字节。
回调函数里局部变量多、调用层级深、用了格式化输出,都可能增加定时器服务任务的栈消耗。调试阶段可以通过栈高水位或栈溢出检查确认是否够用。
四、定时器创建
动态创建软件定时器使用 xTimerCreate():
1 | TimerHandle_t xTimerCreate(const char * const pcTimerName, |
参数含义:
pcTimerName:定时器名称,主要用于调试。xTimerPeriodInTicks:定时周期,单位是 Tick。常用pdMS_TO_TICKS()从毫秒转换。uxAutoReload:定时器类型。pdTRUE表示周期定时器,到期后自动重新开始;pdFALSE表示单次定时器,到期执行一次回调后进入休眠状态。pvTimerID:用户自定义 ID。多个定时器共用一个回调函数时,可以用它区分是哪一个定时器到期。pxCallbackFunction:定时器到期后执行的回调函数。
新创建的定时器处于休眠状态,也就是 Dormant。创建只是在内核里创建了定时器对象,不代表它已经开始计时。必须调用 xTimerStart() 或 xTimerReset() 后才会进入运行状态。
一个单次定时器示例:
1 | static TimerHandle_t onceTimer; |
一个周期定时器示例:
1 | static TimerHandle_t periodicTimer; |
定时器类型在创建后不能修改。单次和周期要在创建时想好,如果确实要改变行为,通常重新创建一个定时器,或者在回调里根据业务状态决定是否再次启动单次定时器。
五、定时器 ID 和共享回调
pvTimerID 用于给定时器绑定一个用户指针。多个定时器共用同一个回调函数时很有用。
1 | typedef enum |
创建时传入不同 ID:
1 | TimerHandle_t ledTimer = xTimerCreate("led", |
如果 ID 是整数,转换成 void * 再转回来要注意平台宽度。更稳的写法是传入静态对象地址,比如传结构体指针:
1 | typedef struct |
回调里再把 pvTimerGetTimerID() 转回 TimerContext_t *。
如果用 uintptr_t 做整数和指针之间的转换,需要包含 <stdint.h>。如果不想考虑这些转换细节,直接传结构体指针更清楚。
六、启动、停止、复位
软件定时器的启动、停止、复位本质上都是向定时器命令队列发送命令。
1. 启动定时器
1 | BaseType_t ret = xTimerStart(onceTimer, pdMS_TO_TICKS(10)); |
第二个参数 xTicksToWait 表示向定时器命令队列发送命令时,最多等待多久。如果命令队列满了,且等待超时,函数返回 pdFAIL。
xTimerStart() 对休眠状态的定时器是启动;对已经运行的定时器,通常相当于重新计算到期时间。实际想表达“重新从现在开始计时”时,用 xTimerReset() 更明确。
ISR 中启动定时器要用 xTimerStartFromISR():
1 | void EXTI0_IRQHandler(void) |
2. 停止定时器
1 | BaseType_t ret = xTimerStop(onceTimer, pdMS_TO_TICKS(10)); |
停止后定时器进入休眠状态,不再计时。停止一个已经停止的定时器通常不会造成问题,但仍然要检查返回值,确认停止命令是否成功进入队列。
宏展开大致是:
1 |
3. 复位定时器
1 | BaseType_t ret = xTimerReset(onceTimer, 0); |
xTimerReset() 的行为:
定时器处于休眠状态时,复位等效于启动。
定时器正在运行时,复位表示从当前时刻重新开始计时。
比如通信超时检测经常这样用:每收到一帧数据,就复位一次超时定时器。如果后续一段时间没有新数据到来,定时器到期,回调里处理超时。
1 | static TimerHandle_t rxTimeoutTimer; |
ISR 中复位要用 xTimerResetFromISR():
1 | void USART1_IRQHandler(void) |
七、修改周期和查询状态
修改周期使用 xTimerChangePeriod():
1 | BaseType_t ret = xTimerChangePeriod(periodicTimer, |
宏展开大致是:
1 |
xNewPeriod 是新的周期,单位是 Tick。xTicksToWait 仍然是等待命令队列有空间的时间,不是新周期。
底层通用命令函数可以这样理解:
1 | BaseType_t xTimerGenericCommand(TimerHandle_t xTimer, |
xCommandID 表示启动、停止、复位、改周期等命令;xOptionalValue 对改周期命令来说就是新周期;pxHigherPriorityTaskWoken 用于 ISR 版本;xTicksToWait 用于任务上下文等待命令队列空间。
常用查询函数:
1 | TickType_t period = xTimerGetPeriod(periodicTimer); |
xTimerGetPeriod() 返回当前周期,单位是 Tick。xTimerIsTimerActive() 返回 pdTRUE 表示定时器处于运行状态;返回 pdFALSE 表示处于休眠状态,也就是未启动、已停止或单次定时器执行完毕。
如果需要删除定时器,可以用:
1 | xTimerDelete(periodicTimer, pdMS_TO_TICKS(10)); |
删除同样是向命令队列发送命令。删除后不要继续使用原来的定时器句柄。
八、启动时机和命令队列时序
软件定时器一个容易误判的点是:定时器计时起点和定时器服务任务实际处理命令的时间不是一回事。
调用 xTimerStart() 或 xTimerReset() 时,命令会带着当前 Tick 一起进入定时器命令队列。定时器到期时间通常按“命令发送时刻 + 定时周期”计算,而不是按“定时器服务任务真正取出命令的时刻 + 定时周期”计算。
所以原笔记里那句“发送到队列里就开始执行了,而不是从定时器服务任务执行指令的时刻开始算”,核心意思是对的。更严谨地说:定时器服务任务处理命令可能有延迟,但命令里记录了命令发送时的时间,内核会基于这个时间计算到期点。
这会带来两个现象:
如果定时器服务任务很快处理命令,表现就像正常从调用
xTimerStart()时开始计时。如果定时器服务任务被高优先级任务长期压住,等它终于处理命令时,定时器可能已经过期,于是回调会很快执行,甚至看起来像“刚启动就到期”。
这不是软件定时器精度突然变高或变低,而是定时器服务任务被延迟调度造成的。要减少这种情况,要么提高定时器服务任务优先级,要么减少高优先级任务长时间占用 CPU,要么让回调和相关命令更轻。
九、单次定时器和周期定时器
单次定时器对应 CMSIS-RTOS 里的 osTimerOnce,FreeRTOS 原生创建时 uxAutoReload 传 pdFALSE。它到期执行一次回调,然后进入休眠状态。如果还想再用,需要重新 xTimerStart() 或 xTimerReset()。
周期定时器对应 CMSIS-RTOS 里的 osTimerPeriodic,FreeRTOS 原生创建时 uxAutoReload 传 pdTRUE。它每次到期执行回调,然后自动进入下一轮计时。
单次定时器适合超时、延迟动作、按键消抖窗口等场景。周期定时器适合心跳、状态检查、低频采样等场景。
按键消抖可以用单次定时器:
1 | static TimerHandle_t keyDebounceTimer; |
这里每次外部中断都复位消抖定时器,只有电平稳定一段时间后,回调才真正判断按键状态。
十、注意事项
软件定时器不是硬件定时器,精度受 Tick 周期、任务调度、定时器服务任务优先级影响。
定时器回调运行在定时器服务任务中,不是在中断中。回调里不要调用会阻塞的 API,不要做长时间计算。
所有定时器共用一个定时器服务任务。一个回调写得太重,会影响其他所有软件定时器。
xTimerStart()、xTimerStop()、xTimerReset()、xTimerChangePeriod() 都是发命令到定时器命令队列,返回 pdPASS 只表示命令成功进入队列,不表示命令已经执行完成。
xTicksToWait 是等待命令队列空间的时间,不是定时器周期。
xTimerStartFromISR()、xTimerResetFromISR() 等 ISR 版本需要配合 pxHigherPriorityTaskWoken 和 portYIELD_FROM_ISR()。
单次定时器到期后进入休眠状态,周期定时器到期后自动重新计时。定时器类型创建后不能修改。
定时器服务任务优先级太低会导致回调延迟;定时器命令队列太短会导致启动、停止、复位、改周期等操作失败;定时器服务任务栈太小会导致系统不稳定。







