系统里没个准的时间基准,跑 FreeRTOS 的项目总觉得心里不踏实。做嵌入式这几年,我接过不少 STM32 + FreeRTOS 的活,从鱼缸温控到数字电源、逆变器、四开关升降压,凡是带“实时”两个字的,最后都会绕回到同一个问题:任务调度要看时间,超时要看时间,性能统计要看时间,日志时间戳也要看时间。而大部分工程默认用的 HAL_GetTick,只能提供毫秒级精度,还不够稳。后来我在项目里把 TIM6 单独拎出来,配合 FreeRTOS 做成了一套高精度系统时间源,实测下来稳定性和精度都比以前强了不少,这篇就把完整做法和踩过的坑一次说清楚。
这套方案适合谁?只要你在用 STM32 + FreeRTOS,并且对时间有更高要求——比如需要微秒级时间戳、需要看每个任务吃了多少 CPU、需要把超时管理做得更精细,那下面这些内容可以直接抄作业。原理不难,TIM6 是基本定时器,只有时基功能,没有输入捕获、没有 PWM 输出,但正因为“功能少”,它跑时间基准反而最稳,不会被人误配成别的用途。我会从为什么选 TIM6 开始,逐步拆到分频计算、中断实现、API 设计,最后再讲接入 FreeRTOS 运行时间统计和排坑经验,尽量把每一个“为什么这么做”都讲透。
1. 为什么需要一套独立的高精度时间源
1.1 只靠 HAL_GetTick 的三个硬伤
很多刚从标准库切到 HAL 库的人,习惯性把所有时间管理都压到 HAL_GetTick 上。HAL_GetTick 本身是基于 SysTick 的,默认 1ms 递增一次。粗略用没问题,但一旦涉及性能和精度,三个问题就会暴露出来。
第一个是分辨率太低。1ms 的 tick 粒度,意味着你想测一个函数到底跑了 300us 还是 800us,根本测不出来。对于电机控制里的换相时间、协议栈里的超时重传、电源环路里的保护动作时间,这个精度远远不够。
第二个问题是 SysTick 已经被 FreeRTOS 接管了。FreeRTOS 内核默认拿 SysTick 当系统节拍,用来做任务调度和时间的 tick 计数。如果你在应用层也拿 HAL_GetTick 来打时间戳,等于跟调度器共享同一个时基,双方都在改同一个计数器相关的状态,很容易出现边界竞争。而且 HAL_GetTick 在中断里依赖uwTick这个全局变量累加,一旦你调整了 SysTick 的中断优先级,或者写了自定义的 SysTick_Handler,HAL_GetTick 就可能不更新,排查起来非常隐蔽。
第三个问题是时间戳容易受到调度影响。HAL_GetTick 只提供软件计数值,它反映的是“SysTick 中断执行到哪了”,而不是“当前真正的时间点”。如果你在任务里连续读两次 HAL_GetTick,可能拿到同一个值,也可能因为任务被抢占,跳变的间隔远大于真实经过的时间。
所以,当一个系统里既要有任务调度,又要有细粒度时间测量,我习惯的做法是:让 SysTick 继续给 FreeRTOS 做 tick,另外再找一个硬件定时器专职做高精度时间基准。这个定时器,首选就是 TIM6。
1.2 TIM6 为什么是时间基准的首选
在 STM32F1、F4 这些常用系列里,TIM6 和 TIM7 是基本定时器,挂在 APB1 总线上。它有预分频器 PSC、16 位计数器 CNT、自动重装载寄存器 ARR,能产生更新中断,也能触发 DAC,但不像通用定时器那样带输入捕获、输出比较、编码器接口。
正是这种“功能少”,让它很适合当专职时钟源。通用定时器比如 TIM2、TIM3,经常要留着做 PWM、输入捕获、编码器测速,你很难保证一个项目里这些资源不会被别处占用。TIM6 没有这些高级功能,配置好后基本不会有人去动它,专门用于维护系统时间,代码审查时逻辑也清晰。
另外,TIM6 的时钟源来自 APB1 定时器时钟。以 STM32F103 为例,外部 8MHz 晶振经过 PLL 倍频到 72MHz 系统时钟,AHB 是 72MHz,APB1 最大到 36MHz。这里有个新手很容易踩的坑:APB1 预分频器为 2 时,定时器时钟不是 APB1 的 36MHz,而是自动倍频成 72MHz。也就是说,TIM6 的计数时钟其实就是 72MHz,跟 SysTick 的时钟源同频。这个特性非常关键,因为 72MHz 时钟下,你只要把预分频设为 71,就能得到 1MHz 的计数频率,计数器每走 1 格就是 1 微秒,处理起来非常直观。
有人会问,既然 SysTick 也是 72MHz,为什么不用 SysTick 提高频率来获取高精度时间?因为 FreeRTOS 对 SysTick 的配置有自己的要求,你把 SysTick 频率改成 1MHz,中断频率会升到 1MHz,对调度器来说没必要而且开销很大。TIM6 独立于内核,你让它跑多快都不影响任务调度,这是本质区别。
1.3 这套方案直接受益的几类项目
在做过的项目里,下面这类场景对时间基准的要求最典型。
电机控制和逆变器项目需要快速记录保护动作时间。FOC 控制里电流环跑到 10kHz 甚至 20kHz 很常见,一旦过流保护触发,你要知道从故障发生到保护执行经过了多长时间,单靠 ms 级 tick 根本分析不了。用 TIM6 做微秒时间戳,就可以精确定位故障点,也能评估中断响应的实时性。
数字电源和升降压电路需要环路周期统计。四开关 buck-boost、双向 DC-DC 这类拓扑,软启动时间和保护延时的精度直接影响可靠性。项目里把 TIM6 服务函数插到主环路里做时间基点,环路周期抖动一眼就能看出来。
多任务系统里的 CPU 占用率统计也需要时间源。FreeRTOS 提供了任务运行时间统计功能,vTaskGetRunTimeStats可以打印每个任务占用的 CPU 比例,但它要求你提供一个高频率的时间基准计数器。没有这个计数器,统计结果要么不准,要么根本编译不过。TIM6 正好补上这块。
至于鱼缸温控、温湿度计这类相对低速的场景,微秒级时间戳可能没那么必要,但统一的系统时间 API 仍然能降低代码复杂度,后面我们封装的tm_get_us(),拿来做 100ms 温度的采集周期、1s 的 LCD 刷新周期,结构上会更干净。
2. 整体设计:让 TIM6 输出微秒级时间戳
2.1 硬件计数为主、软件累加为辅的核心思路
TIM6 是 16 位计数器,最大计数到 65535。如果让它按 1us 递增,那么 65.535ms 就会回绕一次。你要么把中断频率设为 1MHz,每次溢出都进中断,但 1us 进一次中断对 Cortex-M3 来说负担偏大,还会频繁打断任务调度;要么把中断周期拉长,只在较低频次里累加软件计数,把高精度留给硬件 CNT。
我采用的做法是:TIM6 计数频率仍然设为 1MHz,ARR 设为 999,也就是每计满 1000 个数(1ms)触发一次更新中断。在中断服务函数里,软件维护一个以微秒为单位的 64 位累加值;在外部读时间时,直接把当前 CNT 的实时计数值和软件累加值拼起来,就能得到从启动到当前的微秒级时间戳。
这个设计的关键在于:硬件 CNT 是持续自由运行的,不受中断响应延迟影响。中断晚进来几个微秒,软件累加值可能暂时落后,但 CNT 已经真实记录了流逝的时间。所以最终读出的时间戳精度接近 1us,而不是受中断抖动影响。
举个生活化的例子:这就像一个人一边跑步一边报里程,你不需要每米都喊一次“到 1 米了”,而是等他每跑满 1 公里喊一次,你自己看当前脚下的里程碑刻度就能算出精确距离。TIM6 的 CNT 就是里程碑刻度,软件累加值就是公里数。
2.2 分频计算:从 72MHz 到 1MHz 的换算
要得到 1MHz 的计数频率,预分频系数 PSC 的计算公式是:
计数器频率 = 定时器时钟 / (PSC + 1)
已知定时器时钟为 72MHz,要让计数器频率为 1MHz,则 PSC + 1 = 72,即 PSC = 71。
0 到 999 循环,也就是 ARR = 999,计数周期是 1000 个时钟节拍,每个节拍 1us,所以更新中断周期为 1ms。总结公式如下:
中断周期 = (PSC + 1) / 定时器时钟 × (ARR + 1) = (72 / 72MHz) × 1000 = 1ms
如果你希望中断频率更高,比如 100us 进一次中断,可以把 ARR 设为 99,这样在需要更快响应外部事件时也能调节。不过对于系统时间基准,1ms 中断搭配 1us 硬件刻度已经足够,性价比最高。
很多人在 CubeMX 里配置 TIM6 时会疑惑:APB1 时钟明明是 36MHz,为什么 TIM6 的时钟源却显示 72MHz?这里记住一句话:只要 APB1 预分频系数不为 1,定时器时钟就会自动倍频到系统时钟频率。所以 F103 系列里 TIM6、TIM7 都是 72MHz,F407 里如果系统时钟是 168MHz,APB1 是 42MHz,那 TIM6 就是 84MHz,配置时要重新算 PSC。
2.3 中断优先级与 FreeRTOS 的边界约束
TIM6 的中断服务函数极其轻量,理论上可以给一个比较高的优先级。但在 FreeRTOS 环境里,中断优先级的设置有一条红线:如果中断服务函数中要调用带 FromISR 后缀的 FreeRTOS API,那么该中断的优先级不能高于configMAX_SYSCALL_INTERRUPT_PRIORITY配置的值。否则,临界区内关闭中断时,高优先级中断可能会调用 FreeRTOS API,导致系统卡死或数据竞争。
我们这套方案里,TIM6 中断只做时间累加,不调用任何 FreeRTOS API,所以优先级设置相对自由。不过从工程习惯出发,我还是建议把它设为一个中等偏高的抢占优先级,比如在 4 位抢占优先级的系统里设到 5 或者 7,既保证时间戳及时更新,又不会比 HardFault、内存管理这类内核异常还高。
还要注意 SysTick 的优先级。FreeRTOS 移植时通常会把 SysTick 配置为最低优先级,这是为了保证内核临界区不会被 tick 中断打断。TIM6 如果设得比 SysTick 高,理论上是可行的,但你要清楚:TIM6 中断里不能调用任何会阻塞、切换任务的操作,否则就会跟低优先级的 SysTick 产生调度时序上的新问题。最安全的设计是 TIM6 中断保持简洁,只做“加计数”这一件事,其他逻辑全部放到任务里做。
3. 实操代码:从 CubeMX 配置到时间 API 落地
3.1 CubeMX 里的 TIM6 初始化步骤
打开 CubeMX,在 Pinout & Configuration 里找到 Timers,选择 TIM6。激活时钟源 Internal Clock,然后配置参数。
需要填三个关键参数:
- Prescaler (PSC):71
- Counter Mode:Up
- Counter Period (AutoReload Register):999
这里我把 Counter Period 理解为自动重装载值 ARR,即计数到 999 后回绕,正好 1ms 进一次更新中断。
在 NVIC Settings 里勾选 TIM6 global interrupt。如果你用的是 STM32F103 系列,中断号通常是 TIM6_DAC_IRQn,因为 TIM6 的更新中断和 DAC 触发中断共用同一个向量。
CubeMX 生成的MX_TIM6_Init函数大致长这样:
void MX_TIM6_Init(void) { TIM_MasterConfigTypeDef sMasterConfig = {0}; htim6.Instance = TIM6; htim6.Init.Prescaler = 71; htim6.Init.CounterMode = TIM_COUNTERMODE_UP; htim6.Init.Period = 999; htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_Base_Init(&htim6) != HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&htim6, &sMasterConfig) != HAL_OK) { Error_Handler(); } }MasterOutputTrigger配置为TIM_TRGO_RESET即可,因为我们不用 TIM6 触发 ADC 或 DAC,只用到它的时基功能。不要小看这一步,有些人在这里顺手把触发源改成别的,后面调试时会被莫名其妙的联动搞晕。
3.2 中断服务函数:两种写法对比
如果是用 HAL 库,CubeMX 会自动生成中断向量函数,在 stm32f1xx_it.c 里就有一句:
void TIM6_DAC_IRQHandler(void) { HAL_TIM_IRQHandler(&htim6); }然后你在任意用户文件里实现HAL_TIM_PeriodElapsedCallback回调即可。这是 HAL 方式的标准流程,代码可读性好,但我个人偏好更精简的寄存器写法,尤其是当 TIM6 服务频率较高时:
void TIM6_DAC_IRQHandler(void) { if ((TIM6->SR & TIM_SR_UIF) != 0) { TIM6->SR = (uint16_t)~TIM_SR_UIF; // 清更新中断标志 g_tim6_tick_us += TIM6_PERIOD_US; // 软件累加 1000us } }两种写法都能工作。HAL 方式的优势是框架统一、出错概率低;寄存器方式的优势是省掉了HAL_TIM_IRQHandler里一些多余的状态判断,中断延迟更短,代码也更透明。对于时间基准这种核心逻辑,我更推荐看得懂的寄存器方式,至少出了问题你能一眼看到中断标志是怎么处理的。
TIM6_PERIOD_US可以宏定义成 1000UL,对应 ARR + 1 后换算的微秒数。以后如果调整 ARR,只改宏和 CubeMX 配置即可,不会牵连到其他地方。
g_tim6_tick_us这个全局变量建议定义为volatile uint64_t,因为中断和主循环都会读写它,不加 volatile 在开了编译器优化后可能读到陈旧值。中断里累加,应用层读取,这个变量就是时间模块的核心状态。
3.3 时间获取接口与回绕边界处理
有了硬件计数和软件累加,获取微秒级时间戳的接口就可以这样写:
uint64_t tm_get_us(void) { uint32_t primask; uint64_t ticks; uint32_t cnt; primask = __get_PRIMASK(); __disable_irq(); if ((TIM6->SR & TIM_SR_UIF) != 0) { TIM6->SR = (uint16_t)~TIM_SR_UIF; g_tim6_tick_us += TIM6_PERIOD_US; } cnt = TIM6->CNT; ticks = g_tim6_tick_us + cnt; __set_PRIMASK(primask); return ticks; }这里为什么要关中断?因为如果先读 CNT 再读软件累加值,中间发生了一次更新中断,软件累加值已经 +1000,但 CNT 已经回绕从 0 重新计数,两者组合出来的时间戳就错了。关中断可以保证“检测 UIF、累加、读 CNT”这个序列是原子的。
那 UIF 补偿逻辑又怎么回事?中断里每 1ms 加一次软件累加值,但如果你在主循环读时间时,CNT 刚刚回绕到 0,中断还没来得及执行,UIF 标志已经置位,这时候软件累加值还是旧值。如果不做补偿,读出来的时间戳会少 1000us。所以我们先判断 UIF,如果已经置位,就在关中断环境下主动补上这一次累加,并清掉标志,这样中断后面执行时就不会重复加。
注意:清理标志必须在临界区里做,不能让 ISR 同时操作,否则会出现一次性加了两倍的错误。这条逻辑我在实际项目中踩过坑,专门拿出来讲,就是希望大家不要等到线上出了问题再来翻。
接口返回uint64_t,可以表示很长的时间范围而不用担心回绕。如果你只需要毫秒,也可以封装一层:
uint64_t tm_get_ms(void) { return tm_get_us() / 1000ULL; }3.4 接入 FreeRTOS 任务运行时间统计
FreeRTOS 的任务运行时间统计功能默认是关闭的,需要手动打开。在 FreeRTOSConfig.h 里做两处修改:
#define configGENERATE_RUN_TIME_STATS 1 #define configUSE_TRACE_FACILITY 1然后提供两个宏:
#define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS() do { } while(0) #define portGET_RUN_TIME_COUNTER_VALUE() ((uint32_t)(tm_get_us() & 0xFFFFFFFFUL))第一次接这个功能的朋友容易在portCONFIGURE_TIMER_FOR_RUN_TIME_STATS里再去初始化一遍 TIM6,这是多余的,甚至会因为重新初始化导致定时器计数被清零。更合理的做法是:在main函数进入调度器之前,已经调用过MX_TIM6_Init并启动了 TIM6,那么这个宏直接留空,让系统时间模块保持原有初始化逻辑即可。
调度器启动后,如果需要查看各任务 CPU 占用率,可以先分配一块足够大的缓冲区,然后调用:
char stat_buf[1024]; vTaskGetRunTimeStats(stat_buf);统计结果会按照各任务累计运行时间从高到低排列,输出为 ASCII 表格。每个任务占 CPU 的比例就是它的运行时间除以总运行时间。有了微秒级计数器,短任务也能被准确统计到,不会出现“小任务显示 0%”的情况。
实际上,如果你的系统时间接口已经能精确到微秒,那它不仅能服务运行时间统计,还能用来做等待超时。比如接收队列时,你可以先把deadline = tm_get_us() + 5000算好,再循环判断剩余时间,这样就能实现比vTaskDelay更细粒度的超时控制。不过要注意此时内核的 tick 仍然是 SysTick,时间单位不同,使用前要转换清楚。
4. 常见问题与排坑实录
4.1 TIM6 中断不进,时间戳纹丝不动
现象是编译下载后,读tm_get_us()一直是 0 或者固定值。最常见的原因有三个。
第一个是中断没开启。CubeMX 配置了 NVIC 之后,底层代码会帮你调用HAL_NVIC_EnableIRQ(TIM6_DAC_IRQn),但如果你用寄存器方式重写了中断函数,又在别处把 NVIC 配置覆盖了,或者手动改了中断优先级,就可能出现中断始终不触发。
第二个是用 HAL 库方式但回调没生效。HAL_TIM_IRQHandler最终会调用HAL_TIM_PeriodElapsedCallback,但这个回调是弱函数,如果你没有实现自己的版本,或者函数名拼写错了,中断确实进来了,但什么都没做。调试时可以在回调第一行加个断点,看能不能停住。
第三个是定时器根本没启动。CubeMX 只负责初始化,不会自动开启计数。你必须调用:
HAL_TIM_Base_Start_IT(&htim6);如果使用寄存器方式,则需要自己设置 TIM6 的 CEN 位。这条特别容易漏,因为很多外设初始化后稍微操作一下寄存器就能工作,但定时器必须先启动。
4.2 时间戳跳变和回绕边界问题
最典型的表现是:读出的时间戳偶尔会突然变大或者变小,间隔不规律。
如果你直接写了一个不经过临界区的读取函数,比如先读 CNT 再读软件累加值,那回绕瞬间的组合错误就是罪魁祸首。前面给出的tm_get_us已经通过关中断 + UIF 检测解决了这个问题。
还有一个隐藏坑:如果你的代码里用了uint32_t保存返回值,而系统已经运行超过约 71 分钟(2^32 微秒约等于 4294 秒,实际不到 72 分钟),时间戳就会回绕到 0。这时候任何基于时间戳的先后判断都要用“差值小于半周期”的规则,而不是直接比较大小。既然接口返回了uint64_t,我建议应用层就直接用uint64_t承接,不要在中间截断成 32 位,除非你有充分的性能考虑。
4.3 中断里必须避开的 FreeRTOS 操作
TIM6 中断是我的时间核心,但它同时也是 FreeRTOS 系统里的一个外部中断。如果你在中断里调用vTaskDelay、xQueueReceive这类会阻塞的 API,系统会直接硬故障,这是 FreeRTOS 的铁律,不是代码风格问题。
即使是非阻塞的 FromISR API,也要注意优先级设置。比如xQueueSendFromISR这类调用,要求中断优先级数值必须不小于configMAX_SYSCALL_INTERRUPT_PRIORITY。很多移植默认configMAX_SYSCALL_INTERRUPT_PRIORITY是 5,如果你的 TIM6 抢占优先级设为 0(数值最小、优先级最高),那么中断里调用这些函数就可能破坏内核数据结构。
时间模块的定位是“绝对纯净”。我在项目里始终要求这个中断服务函数只能做时间累加,不干别的。哪怕想在里面加个计数器翻转 GPIO,也应该在确认不会引入额外阻塞和过重负担之后再加。调试阶段为了看频率可以临时加,但正式版本建议摘掉。
4.4 长期精度漂移与校准思路
TIM6 的精度取决于时钟源。如果你的系统用内部 HSI RC 振荡器,精度通常在 ±1% 到 ±2% 级别,即使预分频算得再准,长期运行下来时间还是会漂。要求稍高的项目,建议外部接 8MHz 晶振并把 PLL 配准,这样短期精度能到几十 ppm 级别,多数场景足够。
但如果你做的是时钟同步类应用,比如多个设备之间需要对齐时间戳,只靠 TIM6 还是不够。此时可以用高精度 RTC 或者外部温补晶振做长期校准,让 TIM6 负责高分辨率短时间计时,RTC 负责绝对时间和长时间基准,二者配合,效果会好很多。
还有一个小技巧,调试时可以专门用 TIM6 输出一个 1ms 周期翻转的 GPIO,然后用示波器测量实际周期。如果示波器显示误差在几十微秒以内,说明配置正常;如果偏差达到毫秒级,多半是时钟源没配好,或者中断里代码太重,导致溢出事件处理不及时。
最后再分享两个实际体会
这套 TIM6 时间模块我用在很多项目里,最大的收益不是“时间戳多了几位精度”,而是整个系统的节奏变得可控。以前排查问题是靠HAL_GetTick打点,毫秒级的误差经常让人误判是任务优先级问题还是外设响应慢。换到微秒级时间戳以后,问题定位快了很多。
我个人的习惯是把tm_get_us()、tm_get_ms()单独放到一个tm_time.c文件里,对外只暴露头文件里的几个接口。任务里要用时间,一律调用这两个函数,禁止直接访问g_tim6_tick_us全局变量。这样以后想换时钟源、想接外部同步、想扩展纳秒级计数器,都只需要改这个模块,其他代码一行不用动。
最后再分享一个小技巧:调试时间模块时,不要只盯着串口打出来的数字,把数字和实际波形对比才可靠。在 TIM6 中断里翻转一个空闲 GPIO,用示波器看周期,再在任务里调用tm_get_us()打印一组时间差,两边对照,基本就能确认模块有没有问题。嵌入式这行,纸面上算得再准,都不如示波器探头晃一下来得实在。