FreeRTOS 软件定时器适合对精度要求不高、但又需要周期触发或延时触发的场景。它不是硬件定时器,不会占用某个 TIM 外设,也不会在硬件中断里直接执行回调。软件定时器依赖 FreeRTOS 的 Tick 和一个专门的定时器服务任务。

如果要做 PWM、输入捕获、精确采样这类强实时功能,还是应该用硬件定时器。如果只是 500ms 翻转一次 LED、3s 后超时处理、周期性检查状态,用软件定时器更方便。

一、软件定时器的运行机制

软件定时器启用后,FreeRTOS 内核会创建两个核心对象:

  1. 定时器服务任务:也叫 Timer Service Task 或 Daemon Task。它本身也是一个普通 FreeRTOS 任务,由调度器调度运行。

  2. 定时器命令队列:任务或 ISR 调用 xTimerStart()xTimerStop()xTimerReset() 等函数时,并不是直接操作定时器链表,而是把命令发送到这个队列里,由定时器服务任务取出后执行。

因此,软件定时器的操作路径大致是:

1
2
3
4
5
用户任务/ISR 调用 xTimerStart()
-> 命令写入定时器命令队列
-> 定时器服务任务被调度运行
-> 定时器服务任务处理命令、维护到期时间
-> 定时器到期后,在定时器服务任务中执行回调函数

这里有两个关键点。

第一,定时器回调函数不是在中断里运行,而是在定时器服务任务上下文里运行。所以回调函数里可以调用一部分 FreeRTOS 任务级 API,但不能做会长时间阻塞的操作。

第二,所有软件定时器共享同一个定时器服务任务。某一个定时器回调执行太久,会影响其他软件定时器的回调执行,也会影响后续定时器命令的处理。

二、回调函数不能阻塞

定时器到期后执行的是用户注册的回调函数:

1
2
3
4
static void LedTimerCallback(TimerHandle_t xTimer)
{
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}

这个函数运行在定时器服务任务里。回调里可以做很短的状态更新、置标志、发送通知、发送队列消息,但不要调用 vTaskDelay(),也不要调用可能等待很久的队列、信号量、互斥量函数。

比如下面这种写法不合适:

1
2
3
4
static void BadTimerCallback(TimerHandle_t xTimer)
{
vTaskDelay(pdMS_TO_TICKS(100));
}

原因不是“回调函数不能调用任何 FreeRTOS API”,而是定时器服务任务被这个回调卡住后,其他软件定时器的回调和命令队列都会被拖住。更合适的做法是回调里只通知业务任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static TaskHandle_t workTaskHandle;

static void TimerCallback(TimerHandle_t xTimer)
{
xTaskNotifyGive(workTaskHandle);
}

void WorkTask(void *argument)
{
workTaskHandle = xTaskGetCurrentTaskHandle();

for (;;)
{
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
DoSlowWork();
}
}

回调负责“触发”,任务负责“耗时处理”。这是软件定时器比较稳的用法。

三、CubeMX 和 FreeRTOS 配置

CubeMX 里软件定时器相关配置通常对应 FreeRTOSConfig.h 里的几个宏。不同 CubeMX 版本显示名称可能略有差别,但含义基本一致。

  1. configUSE_TIMERS / USE_TIMERS

这个宏用于启用软件定时器功能。设为 1 后,FreeRTOS 会创建定时器服务任务和定时器命令队列。如果工程里用了事件组的 xEventGroupSetBitsFromISR(),或者用了软件定时器,一般都需要启用它。

CubeMX 生成工程时,有些配置启用后会灰掉,不一定允许在界面里直接关闭。这种情况下以生成的 FreeRTOSConfig.h 为准。

  1. configTIMER_TASK_PRIORITY / TIMER_TASK_PRIORITY

这个宏设置定时器服务任务的优先级。定时器服务任务优先级越高,定时器命令和回调越容易及时处理;优先级太低时,如果系统里一直有更高优先级任务运行,定时器回调就会延迟。

它仍然是任务优先级,不是中断优先级。它不会比硬件中断更高,也不会打断正在执行的 ISR。

配置时可以按这个思路判断:

  1. 如果软件定时器只是做普通状态检查,优先级可以不高。

  2. 如果软件定时器用于通信超时、控制状态机推进、事件组 FromISR 的延后处理,就不要把它设得太低。

  3. 如果回调里做的事比较多,优先级设高只会让它更容易抢占其他任务,不会解决回调本身耗时的问题。

  4. configTIMER_QUEUE_LENGTH / TIMER_QUEUE_LENGTH

这个宏设置定时器命令队列长度。启动、停止、复位、改周期、删除定时器等操作都会发送命令到这个队列里。

如果很多任务或 ISR 在短时间内频繁操作软件定时器,队列太短就可能导致命令发送失败,相关 API 返回 pdFAIL。这时不是定时器本身坏了,而是命令没有成功进入队列。

小工程里 5-10 往往够用;如果软件定时器多,或者事件组 FromISR 使用频繁,可以适当增大。具体还是看项目里同时投递命令的峰值。

  1. configTIMER_TASK_STACK_DEPTH / TIMER_TASK_STACK_DEPTH

这个宏设置定时器服务任务栈大小,单位通常是 word,不是 byte。STM32F4 这种 32 位平台上,一个 word 一般是 4 字节。

回调函数里局部变量多、调用层级深、用了格式化输出,都可能增加定时器服务任务的栈消耗。调试阶段可以通过栈高水位或栈溢出检查确认是否够用。

四、定时器创建

动态创建软件定时器使用 xTimerCreate()

1
2
3
4
5
TimerHandle_t xTimerCreate(const char * const pcTimerName,
const TickType_t xTimerPeriodInTicks,
const UBaseType_t uxAutoReload,
void * const pvTimerID,
TimerCallbackFunction_t pxCallbackFunction);

参数含义:

  1. pcTimerName:定时器名称,主要用于调试。

  2. xTimerPeriodInTicks:定时周期,单位是 Tick。常用 pdMS_TO_TICKS() 从毫秒转换。

  3. uxAutoReload:定时器类型。pdTRUE 表示周期定时器,到期后自动重新开始;pdFALSE 表示单次定时器,到期执行一次回调后进入休眠状态。

  4. pvTimerID:用户自定义 ID。多个定时器共用一个回调函数时,可以用它区分是哪一个定时器到期。

  5. pxCallbackFunction:定时器到期后执行的回调函数。

新创建的定时器处于休眠状态,也就是 Dormant。创建只是在内核里创建了定时器对象,不代表它已经开始计时。必须调用 xTimerStart()xTimerReset() 后才会进入运行状态。

一个单次定时器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static TimerHandle_t onceTimer;

static void OnceTimerCallback(TimerHandle_t xTimer)
{
TimeoutHandle();
}

void AppInit(void)
{
onceTimer = xTimerCreate("once",
pdMS_TO_TICKS(1000),
pdFALSE,
NULL,
OnceTimerCallback);
configASSERT(onceTimer != NULL);

xTimerStart(onceTimer, 0);
}

一个周期定时器示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static TimerHandle_t periodicTimer;

static void PeriodicTimerCallback(TimerHandle_t xTimer)
{
HeartbeatToggle();
}

void AppInit(void)
{
periodicTimer = xTimerCreate("periodic",
pdMS_TO_TICKS(500),
pdTRUE,
NULL,
PeriodicTimerCallback);
configASSERT(periodicTimer != NULL);

xTimerStart(periodicTimer, 0);
}

定时器类型在创建后不能修改。单次和周期要在创建时想好,如果确实要改变行为,通常重新创建一个定时器,或者在回调里根据业务状态决定是否再次启动单次定时器。

五、定时器 ID 和共享回调

pvTimerID 用于给定时器绑定一个用户指针。多个定时器共用同一个回调函数时很有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typedef enum
{
TIMER_LED = 1,
TIMER_BUZZER,
TIMER_REPORT
} AppTimerId_t;

static void CommonTimerCallback(TimerHandle_t xTimer)
{
AppTimerId_t id = (AppTimerId_t)(uintptr_t)pvTimerGetTimerID(xTimer);

switch (id)
{
case TIMER_LED:
LedTimeout();
break;

case TIMER_BUZZER:
BuzzerTimeout();
break;

case TIMER_REPORT:
ReportTimeout();
break;

default:
break;
}
}

创建时传入不同 ID:

1
2
3
4
5
TimerHandle_t ledTimer = xTimerCreate("led",
pdMS_TO_TICKS(200),
pdFALSE,
(void *)TIMER_LED,
CommonTimerCallback);

如果 ID 是整数,转换成 void * 再转回来要注意平台宽度。更稳的写法是传入静态对象地址,比如传结构体指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
uint8_t channel;
uint32_t timeoutMs;
} TimerContext_t;

static TimerContext_t ledTimerCtx = { .channel = 1, .timeoutMs = 200 };

TimerHandle_t ledTimer = xTimerCreate("led",
pdMS_TO_TICKS(200),
pdFALSE,
&ledTimerCtx,
CommonTimerCallback);

回调里再把 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
2
3
4
5
6
7
8
9
10
11
12
void EXTI0_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;

if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET)
{
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
xTimerStartFromISR(onceTimer, &xHigherPriorityTaskWoken);
}

portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

2. 停止定时器

1
BaseType_t ret = xTimerStop(onceTimer, pdMS_TO_TICKS(10));

停止后定时器进入休眠状态,不再计时。停止一个已经停止的定时器通常不会造成问题,但仍然要检查返回值,确认停止命令是否成功进入队列。

宏展开大致是:

1
2
#define xTimerStop(xTimer, xTicksToWait) \
xTimerGenericCommand((xTimer), tmrCOMMAND_STOP, 0U, NULL, (xTicksToWait))

3. 复位定时器

1
BaseType_t ret = xTimerReset(onceTimer, 0);

xTimerReset() 的行为:

  1. 定时器处于休眠状态时,复位等效于启动。

  2. 定时器正在运行时,复位表示从当前时刻重新开始计时。

比如通信超时检测经常这样用:每收到一帧数据,就复位一次超时定时器。如果后续一段时间没有新数据到来,定时器到期,回调里处理超时。

1
2
3
4
5
6
7
8
9
10
11
12
static TimerHandle_t rxTimeoutTimer;

void UartByteReceived(uint8_t data)
{
PushByteToParser(data);
xTimerReset(rxTimeoutTimer, 0);
}

static void RxTimeoutCallback(TimerHandle_t xTimer)
{
ParserTimeout();
}

ISR 中复位要用 xTimerResetFromISR()

1
2
3
4
5
6
7
8
9
10
11
12
13
void USART1_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;

if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) != RESET)
{
uint8_t data = (uint8_t)(huart1.Instance->DR & 0xff);
PushByteFromISR(data);
xTimerResetFromISR(rxTimeoutTimer, &xHigherPriorityTaskWoken);
}

portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

七、修改周期和查询状态

修改周期使用 xTimerChangePeriod()

1
2
3
BaseType_t ret = xTimerChangePeriod(periodicTimer,
pdMS_TO_TICKS(1000),
pdMS_TO_TICKS(10));

宏展开大致是:

1
2
#define xTimerChangePeriod(xTimer, xNewPeriod, xTicksToWait) \
xTimerGenericCommand((xTimer), tmrCOMMAND_CHANGE_PERIOD, (xNewPeriod), NULL, (xTicksToWait))

xNewPeriod 是新的周期,单位是 Tick。xTicksToWait 仍然是等待命令队列有空间的时间,不是新周期。

底层通用命令函数可以这样理解:

1
2
3
4
5
BaseType_t xTimerGenericCommand(TimerHandle_t xTimer,
const BaseType_t xCommandID,
const TickType_t xOptionalValue,
BaseType_t * const pxHigherPriorityTaskWoken,
const TickType_t xTicksToWait);

xCommandID 表示启动、停止、复位、改周期等命令;xOptionalValue 对改周期命令来说就是新周期;pxHigherPriorityTaskWoken 用于 ISR 版本;xTicksToWait 用于任务上下文等待命令队列空间。

常用查询函数:

1
2
TickType_t period = xTimerGetPeriod(periodicTimer);
BaseType_t active = xTimerIsTimerActive(periodicTimer);

xTimerGetPeriod() 返回当前周期,单位是 Tick。xTimerIsTimerActive() 返回 pdTRUE 表示定时器处于运行状态;返回 pdFALSE 表示处于休眠状态,也就是未启动、已停止或单次定时器执行完毕。

如果需要删除定时器,可以用:

1
xTimerDelete(periodicTimer, pdMS_TO_TICKS(10));

删除同样是向命令队列发送命令。删除后不要继续使用原来的定时器句柄。

八、启动时机和命令队列时序

软件定时器一个容易误判的点是:定时器计时起点和定时器服务任务实际处理命令的时间不是一回事。

调用 xTimerStart()xTimerReset() 时,命令会带着当前 Tick 一起进入定时器命令队列。定时器到期时间通常按“命令发送时刻 + 定时周期”计算,而不是按“定时器服务任务真正取出命令的时刻 + 定时周期”计算。

所以原笔记里那句“发送到队列里就开始执行了,而不是从定时器服务任务执行指令的时刻开始算”,核心意思是对的。更严谨地说:定时器服务任务处理命令可能有延迟,但命令里记录了命令发送时的时间,内核会基于这个时间计算到期点。

这会带来两个现象:

  1. 如果定时器服务任务很快处理命令,表现就像正常从调用 xTimerStart() 时开始计时。

  2. 如果定时器服务任务被高优先级任务长期压住,等它终于处理命令时,定时器可能已经过期,于是回调会很快执行,甚至看起来像“刚启动就到期”。

这不是软件定时器精度突然变高或变低,而是定时器服务任务被延迟调度造成的。要减少这种情况,要么提高定时器服务任务优先级,要么减少高优先级任务长时间占用 CPU,要么让回调和相关命令更轻。

九、单次定时器和周期定时器

单次定时器对应 CMSIS-RTOS 里的 osTimerOnce,FreeRTOS 原生创建时 uxAutoReloadpdFALSE。它到期执行一次回调,然后进入休眠状态。如果还想再用,需要重新 xTimerStart()xTimerReset()

周期定时器对应 CMSIS-RTOS 里的 osTimerPeriodic,FreeRTOS 原生创建时 uxAutoReloadpdTRUE。它每次到期执行回调,然后自动进入下一轮计时。

单次定时器适合超时、延迟动作、按键消抖窗口等场景。周期定时器适合心跳、状态检查、低频采样等场景。

按键消抖可以用单次定时器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static TimerHandle_t keyDebounceTimer;

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;

if (GPIO_Pin == KEY_Pin)
{
xTimerResetFromISR(keyDebounceTimer, &xHigherPriorityTaskWoken);
}

portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

static void KeyDebounceCallback(TimerHandle_t xTimer)
{
if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET)
{
KeyPressedHandle();
}
}

这里每次外部中断都复位消抖定时器,只有电平稳定一段时间后,回调才真正判断按键状态。

十、注意事项

软件定时器不是硬件定时器,精度受 Tick 周期、任务调度、定时器服务任务优先级影响。

定时器回调运行在定时器服务任务中,不是在中断中。回调里不要调用会阻塞的 API,不要做长时间计算。

所有定时器共用一个定时器服务任务。一个回调写得太重,会影响其他所有软件定时器。

xTimerStart()xTimerStop()xTimerReset()xTimerChangePeriod() 都是发命令到定时器命令队列,返回 pdPASS 只表示命令成功进入队列,不表示命令已经执行完成。

xTicksToWait 是等待命令队列空间的时间,不是定时器周期。

xTimerStartFromISR()xTimerResetFromISR() 等 ISR 版本需要配合 pxHigherPriorityTaskWokenportYIELD_FROM_ISR()

单次定时器到期后进入休眠状态,周期定时器到期后自动重新计时。定时器类型创建后不能修改。

定时器服务任务优先级太低会导致回调延迟;定时器命令队列太短会导致启动、停止、复位、改周期等操作失败;定时器服务任务栈太小会导致系统不稳定。