☰
STM32内部HSI时钟精度导致定时器周期偏差的排查与校准
2026/10/28 9:56:24 网站建设 项目流程

上周帮一个客户调STM32C091CCT6的定时器,遇到一个非常典型的问题:代码里明明配置的是1000ms的中断周期,他用逻辑分析仪量GPIO翻转波形,测出来却是995ms。差了5ms,说大不大,说小不小,但对一个用内部HSI做时钟源的项目来说,这个现象几乎可以锁定一个方向——HSI 48MHz的实际频率并不是精准的48.000MHz。

这个问题值得写一篇完整的排查记录。因为STM32C0系列本身定位就是低成本、精简外设、内部时钟优先的MCU,很多人拿到手直接用HSI跑,很少有人会去较真“HSI到底准不准”这件事。但一旦你开始用定时器做精确定时、做RTC补偿、对外通信波特率,这个误差就会变成实实在在的坑。这篇内容我会把排查思路、原理计算、实测数据和修正方案全部展开,花十分钟读完,以后再遇到定时器周期对不上,你至少有三个方向可以查。

1. 现象与问题定义

先说现场情况。板子上电后,PB0翻转一次,逻辑分析仪抓到高电平到下一次高电平的间隔是995ms左右,稳定波动在±0.2ms以内。代码里Timer配置的目标是1000ms进一次更新中断,中断里翻转IO。

1.1 现象描述

本质上这是一个“定时器周期整体偏短”的问题。它不是偶发跳变,也不是温度漂移导致的不稳定,而是每次测量几乎都稳定在995ms附近。这类问题基本可以排除代码逻辑错误,比如中断里面做了耗时操作挤占了下一次中断、或者ARR被意外修改等。重点要查的是时基来源,也就是“定时器每秒数了多少个脉冲”。

如果把995ms这个数字换成百分比,偏差是0.5%。这个幅度放在HSI内部RC振荡器身上非常合理,但如果放在外部晶振HSE身上就有点可疑了。所以第一步怀疑对象就是HSI。

1.2 为什么首先怀疑HSI

STM32C0系列主打低成本,很多型号甚至没有HSE引脚,比如部分封装只支持内部时钟。C091CCT6这个型号属于C0系列里外设相对丰富的料,但内部HSI 48MHz依然是最常用的系统时钟来源。HSI本质是芯片内部的RC振荡器,RC振荡器的频率精度天然受两个因素影响:出厂时的初始误差和运行中的温漂。

STM32的HSI在出厂时会做一次校准,校准值存在系统存储区,上电后自动加载到HSICAL字段。但校准本身只能保证在特定温度和电压点附近把频率拉近到48MHz,不可能做到晶振那样的几十ppm精度。数据手册上HSI48在全温度范围内的精度通常在±1%左右,0.5%的偏差完全在合理区间内。

1.3 关于“995ms”的量级判断

我们可以先做一个快速反推:如果定时器计数周期实际时间比预期短了0.5%,说明定时器时钟实际频率比48MHz高了约0.5%,也就是大约48.24MHz。这个数字对于HSI来说一点都不离谱,恰恰说明HSI确实在正常工作,只是“精度”不像我们默认的那样理想。

这时候要判断的是:0.5%的偏差对当前应用是否可接受。如果只是点个LED,肉眼根本看不出差别;如果是累计计时,一小时就差18秒,一天差7分多钟,这个就要认真对待了。我们先不急着下结论,按顺序把原理和排查过程走一遍。

2. 背后原理:HSI、时钟树与定时器时基

要彻底弄明白995ms从哪来,得先把时钟链路上每个环节拆开看。系统运行时的定时器周期由三个因素共同决定:时钟源频率、预分频器PSC、自动重装载值ARR。这三者任何一个偏离预期,最终周期就会跟着偏。

2.1 HSI 48MHz是什么样一个时钟

HSI 48MHz在STM32C0上是一个内部RC振荡器,上电后不需要外部晶振就可以作为系统时钟。因为省掉了外部晶振,硬件BOM成本降低,PCB面积也省了,这是C0系列的一个核心卖点。但代价就是时钟精度远低于晶振。

RC振荡器是靠内部电容充放电时间常数来产生频率的,工艺偏差、温度变化、供电电压波动都会改变充放电速度。STM32在出厂时会对HSI做一次修调,让它在常温、标称电压下尽量接近48MHz。这个修调值被固化在芯片里,软件可以读到它,也可以在此基础上进一步微调。

2.2 HSI的出厂校准和温漂限制

STM32的HSI校准值在每次复位后自动加载到RCC的时钟控制寄存器里,所以你不需要写任何初始化代码,HSI就会以出厂校准后的状态运行。这个校准值是在测试温度下测出来的,如果你的板子工作环境温度偏离测试点,HSI频率就会偏移。

另外,供电电压也会影响。内核电压越高,RC振荡器频率通常会偏高。很多低功耗应用在电池供电场景下,电压从3.3V掉到3.0V,HSI频率就可能偏移0.3%到0.8%。所以有时候同一个代码,在开发板上用USB供电测出来995ms,换到电池供电再测,可能变成1006ms,这都是正常现象。

2.3 定时器时基计算公式与拆解

定时器溢出周期的计算公式是:

溢出周期 = (PSC + 1) × (ARR + 1) / TIM_CLK

其中TIM_CLK是定时器模块实际拿到的时钟频率,PSC是预分频值,ARR是自动重装载值。这里最容易踩坑的是PSC和ARR都要加1,因为寄存器里存的是“分频系数减1”和“计数值减1”。

举个例子,配置1000ms定时:

  • 系统时钟48MHz
  • 预分频PSC=4799,实际分频4800
  • 自动重装载ARR=9999,实际计数10000次
  • 定时器时钟频率 = 48MHz / 4800 = 10kHz
  • 计数10000次的时间 = 10000 / 10kHz = 1000ms

这个计算看起来很简单,问题在于TIM_CLK到底是不是48MHz。很多人默认TIM_CLK等于系统主频,但在STM32上这不一定成立。

2.4 一个很容易被忽视的点:TIM时钟可能不等于系统主频

在STM32C0上看定时器挂在哪条总线上很重要。APB1和APB2的预分频器如果设置为1,那么该总线上的定时器时钟等于系统主频;但如果APB预分频器大于1,定时器时钟会被加倍。比如APB预分频为2,APB时钟是24MHz,但定时器时钟是48MHz,因为定时器输入时钟被硬件自动同步到APB的2倍频。

如果你在配置系统时钟时用了HAL库的HAL_RCC_ClockConfig(),它会自动处理一部分,但如果你手动修改了APB分频,然后直接用SystemClock频率去算定时器参数,结果就会差一半或者偏差更奇怪。这种问题经常被误判为“定时器慢了一倍”或者“定时器周期不对”,实际上只是时钟树配置没有对齐。

所以在进入下一步排查之前,先确认三件事:

  • 系统时钟是不是48MHz
  • TIM所在总线的APB预分频是多少
  • 定时器实际输入时钟是多少

3. 系统排查:一步步定位偏差来源

排查这种事情不能靠猜,要按顺序来。我一般把排查流程分成四步:确认时钟树配置、核对定时器寄存器、反向推算实际频率、排除测量干扰。每一步都有对应的验证方法。

3.1 第一步:确认系统时钟与总线分频

先用调试器或者直接在代码里读寄存器。最直接的方式是读RCC_CFGR,确认系统时钟源是HSI还是PLL,再确认AHB、APB1、APB2的预分频值。如果你用的是HAL库,可以直接调HAL_RCC_GetSysClockFreq()返回当前系统时钟频率。

C091的典型配置下,默认就是HSI 48MHz作为系统时钟,AHB不分频,APB也不分频。这种配置下定时器时钟也就是48MHz。如果你在这个环节发现APB分频不是1,那就要重点检查定时器的时钟是否存在倍频关系。

这一步的目的不是找bug,而是把时钟链路确认死,排除后面所有关于“定时器时钟不是48MHz”的可能性。

3.2 第二步:核对定时器配置

检查配置寄存器时重点看TIMx_CR1的CKD位,它控制定时器自己的时钟分频,一般写00表示不分频。然后看TIMx_PSC和TIMx_ARR是不是和预期一致。特别要注意PSC和ARR有没有“差1”的情况。

我还遇到过一种情况:代码里先用__HAL_TIM_SET_AUTORELOAD()设置了ARR,之后又被初始化函数重新覆盖。这种问题在寄存器级调用的工程里很容易出现,特别是多个外设初始化文件共用同一个定时器句柄时。

定位方法也很简单:在启动定时器之前加一个断点,把寄存器实际值读出来,比对设定值。HAL库可以用TIMx->PSC和TIMx->ARR直接读。

3.3 第三步:反向推算实际时钟频率

如果PSC=4799、ARR=9999都确认无误,实测周期还是995ms,那就可以反推出定时器时钟实际频率:

实际频率 = (PSC + 1) × (ARR + 1) / 实测周期 = 4800 × 10000 / 0.995 ≈ 48.24MHz

这个结果说明定时器时钟源HSI的实际频率大约是48.24MHz,高于标称的48MHz。到了这一步,问题基本锁定在HSI频率本身,而不是定时器配置。

反推出来的频率还能帮你判断偏差是否在合理范围内。0.5%的误差对HSI来说是正常的,如果反推出来是50MHz或者60MHz,那大概率是时钟源被切到了别的倍频路径,或者是APB分频计算错了。

3.4 第四步:排除测量工具和中断延迟干扰

排查完芯片本身之后,还要确认测量手段是否正确。用逻辑分析仪测GPIO翻转周期时,采样率决定了时间分辨率。比如逻辑分析仪采样率是1MHz,那么每个采样点间隔1µs,测一个1s的周期,误差最多也就1µs量级,不会产生5ms的偏差。除非采样率远低于信号频率,否则测量工具本身不太可能引入这么大误差。

中断延迟也不能忽略。如果ISR里做了大量浮点运算、打印日志、甚至调用HAL_Delay,那么中断响应时间会拉长。但要注意,定时器更新中断如果发生了“来不及响应”的情况,表现出来的是周期抖动或者偶发漏中断,而不是稳定偏短。稳定995ms这种特征,更像是时基本身偏快,而不是软件延迟。

4. STM32C091CCT6实测记录与计算结果

为了验证上面的分析,我搭了一个最简单的测试环境,把整个排查过程量化一遍。如果你手上有同型号芯片,可以直接照着测。

4.1 测试环境

  • 主控:STM32C091CCT6
  • 开发板:自制最小系统板,3.3V供电,去耦电容齐全
  • 调试工具:ST-Link V2
  • 测量工具:逻辑分析仪(采样率25MHz@8通道)+ 示波器交叉验证
  • 编译环境:STM32CubeIDE 1.15,HAL库

初始化代码使用HSI作为系统时钟,不启用PLL,AHB和APB都不分频。

RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI; RCC_OscInitStruct.HSIState = RCC_HSI_ON; RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT; RCC_OscInitStruct.HSIDiv = RCC_HSI_DIV1; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_NONE; HAL_RCC_OscConfig(&RCC_OscInitStruct); RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_HSI; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV1; RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_0);

定时器用TIM3,配置为PSC=4799,ARR=9999,更新中断翻转PB0。

4.2 实测数据

连续测量10次GPIO翻转周期,结果如下:

次数周期(ms)偏差(ms)折算TIM时钟(MHz)
1995.2-4.848.23
2995.1-4.948.24
3995.3-4.748.22
4995.0-5.048.24
5995.2-4.848.23
6995.1-4.948.24
7995.3-4.748.22
8995.2-4.848.23
9995.0-5.048.24
10995.1-4.948.24

10次测量极差只有0.3ms,说明频率一致性很好,不是热噪声或者供电纹波造成的随机抖动,而是系统性的初始偏差。

再用示波器在相同引脚确认了一遍,测到的周期同样是995ms左右,排除了逻辑分析仪自身时基误差的影响。

4.3 误差来源归纳

从实测数据推算,HSI实际频率约为48.23~48.24MHz,比标称48MHz高了约0.48%。这属于HSI内部的出厂校准残差,加上芯片工作温度与出厂校准温度不同导致的温漂。

如果这个偏差是你不能接受的,需要进一步做校正。但首先要区分一个概念:HSI偏差是固定偏差加漂移的组合,固定偏差可以软件补偿,漂移很难完全消除。对大部分应用来说,只要把固定偏差补偿掉,时基精度就能提升一个量级。

5. 常见问题速查与避坑

做完上面这一整套排查,你可能会发现自己的问题根本不在HSI,而是死在某个更基础的地方。这里我把调试过程中遇到过的高频问题整理成一张速查表,覆盖配置类、时钟源类和测量类三大方向。

5.1 配置类问题

问题现象可能原因排查与解决
定时器周期比预期整数倍偏长APB预分频>1导致TIM时钟被加倍确认TIM所在总线的APB分频,按实际TIM_CLK计算
定时器周期比预期稍长/稍短,偏差在0.5%左右PSC/ARR读写顺序错误或寄存器被覆盖启动前断点检查TIMx->PSC与TIMx->ARR实际值
定时器周期比预期略短0.1%左右PSC或ARR写入时分频值忘记减1重新核对公式 (PSC+1)×(ARR+1)/TIM_CLK
配置值没问题,但运行中周期变化ARR被DMA或其它外设意外修改加断点或MPU写保护,检查谁在写定时器寄存器
周期完全不固定,忽长忽短中断响应超时,更新事件被丢缩短ISR,或改用硬件方式触发,别在ISR里做耗时操作

这里特别说一个容易踩的点:如果你的更新中断里做了HAL_GPIO_TogglePin()之外的事情,比如打印调试信息,那USART波特率如果是基于HSI算出来的,发送本身也可能消耗不等的时间。测试定时器精度时,ISR里最好只翻转IO,不做任何别的事。

5.2 时钟源类问题

问题现象可能原因排查与解决
定时器周期比预期慢一倍APB分频导致TIM时钟=APB×2但没算进去按实际时钟树计算,别拿系统主频直接套
温度升高后周期变长HSI温漂,RC频率随温度变化评估温漂范围,必要时换HSE或加软件补偿
供电从3.3V降到3.0V之后周期变化HSI随供电电压漂移确认电压范围,若精度要求高改HSE
用HSI但配置了PLL,发现周期离谱PLL配置不当或PLL未锁定先确认SYSCLK源是HSI还是PLL,读RCC_CFGR
复位后第一次定时不准,之后正常HSI启动稳定时间不够启动后延时几百us再开启定时器

其中一个经典场景是“GD32 ADC Timer”和“GD32定时器慢了一倍”这类搜索热词。GD32和STM32的定时器时钟树逻辑类似,同样存在APB预分频导致TIM时钟倍频的问题。如果拿STM32的代码直接改到GD32上,先看APB分频配置和HAL库对TIM时钟频率的处理方式,否则程序跑起来定时器周期不对非常正常。

5.3 测量与外部干扰

问题现象可能原因排查与解决
逻辑分析仪测出稳定偏短逻辑分析仪自身时基不准用示波器交叉验证,或用频率计直接测MCO输出
用手摸芯片/板子后周期变化HSI随温度变化,人体热辐射影响这是物理特性,加热环境下评估温漂
板子靠近电机/继电器时周期抖动EMI干扰耦合到振荡器或IO增加滤波电容,IO加RC滤波,改屏蔽
测量IO引脚带了重负载翻转沿变缓,逻辑分析仪触发点偏移IO驱动能力调高,或测量时断开负载

我实测时用25MHz采样率的逻辑分析仪测1S周期,哪怕采样率再低一些,也不至于差出5ms。如果你测出来一个稳定的995ms,先别怀疑设备,多半是芯片时基本身的问题。反过来,如果测出来的周期每次都比上一次少几ms、看起来像在持续漂移,那才需要考虑测量设备或者温度上升过程的影响。

6. 修正与校准方案

到这里,问题已经定位清楚了:HSI实际频率48.24MHz,导致定时器周期从1000ms变成995ms。接下来看怎么修。方案分三档:不动它、硬件换晶振、软件校准。三档的成本和效果差别很大,按需求选。

6.1 接受误差的场景

如果你的应用是LED指示灯、蜂鸣器、按键扫描去抖、传感器周期性采样这类场景,0.5%的误差完全可以接受。按键扫描20ms去抖,实际19.9ms,人感觉不出来。LED呼吸灯1s周期,实际995ms,肉眼根本看不出差别。这类应用没必要增加成本去换晶振,也不要盲目加校准代码增加复杂度。

6.2 硬件层面切换HSE

如果项目对时间精度有硬性要求,比如需要长期累计计时、做电力系统对时、或者对外通信波特率要求严格,那么最好的方案是换外部晶振。STM32C091支持HSE,外部晶振精度通常能做到20ppm甚至更好,比HSI高两个数量级。

代价是硬件上多两颗电容和一个晶振,PCB上多一小块布局空间。对于本来就不缺成本的工控、电力、医疗设备,直接上HSE最省心。软件上把系统时钟源从HSI切到HSE,定时器参数不用改,只要HSE频率还是按12MHz或24MHz选,方便PLL配合。

切换之后的计时精度,我实测过一批STM32C091外部8MHz晶振方案,24小时累计偏差在2秒以内,完全够绝大多数应用场景使用。

6.3 软件校准:实测频率修正ARR/PSC

如果不想改硬件,又想把0.5%的固定偏差补掉,软件校准是最经济的方式。思路很简单:先测出实际周期或实际时钟频率,然后反向修正ARR或PSC。

以本文的实测数据为例:

  • 目标周期1000ms
  • 实测周期995ms
  • 偏差系数K = 995 / 1000 = 0.995

修正方式:把ARR从9999改成(int)(9999 × 0.995) = 9949,这样理论周期变成4800 × (9949+1) / 48.24MHz ≈ 990.0ms。等等,这个结果不对,因为我用了实际频率48.24MHz来计算。这里要统一口径:修正的目标是在保持PSC不变的情况下,减少计数值,使实际周期回到1000ms。

正确计算应该是:

期望的实际计数时间 = 1000ms 定时器时钟实际频率 = 48.24MHz 实际计数频率 = 48.24MHz / (PSC+1) = 48.24MHz / 4800 = 10.05kHz 需要的计数次数 = 1000ms × 10.05kHz = 10050次

注意这里需要的计数次数不是减少而是增加?等一下,我重新推一下。

之前PSC=4799,ARR=9999,理论设计时认为定时器时钟是48MHz。实际时钟是48.24MHz,实际计数频率是48.24MHz/4800=10.05kHz。计数10000次需要的时间是10000/10.05kHz=995ms。要让这个实际时间达到1000ms,需要计数次数 = 1000ms × 10.05kHz = 10050次。所以ARR应该改成10049。这里我刚才写错了,修正一下:因为时钟偏快,要让周期变长、回到1000ms,应该增加计数次数,而不是减少。

同理,如果用理论频率48MHz去算,固定偏差补偿系数应该是:

K = 实际时钟频率 / 标称时钟频率 = 48.24 / 48.00 = 1.005 ARR_new = (int)(9999 × 1.005) ≈ 10049

所以修正后的配置是PSC保持4799,ARR改成10049。这个结果和用实测周期反推是一样的:ARR_new = (ARR+1) × (48.24/48) - 1 ≈ 10049。代码里加一行宏定义就行。

#define HSI_ACTUAL_FREQ_HZ 48240000UL #define HSI_CALIB_FACTOR (HSI_ACTUAL_FREQ_HZ / 48000000.0) #define TIM_ARR_CALIB(x) ((uint32_t)((x + 1) * HSI_CALIB_FACTOR - 1)) TIM3->ARR = TIM_ARR_CALIB(9999);

这样改完之后,定时器周期就能回到1000ms附近。但要注意,这只补偿了固定偏差,温度变化带来的漂移仍然存在。如果需要更高精度,就要使用动态校准。

6.4 动态校准思路

动态校准的核心思想是找一个精准的参考时钟源,用它来实时测量HSI的实际频率,然后动态调整定时器参数。

可行的参考源有三个:

  • LSE外部32.768kHz晶振(如果板上有时钟晶振)
  • 外部输入的标准频率信号(比如GPS秒脉冲、工频50Hz过零信号)
  • 内部LSI(精度太差,只能做粗校准)

以LSE为参考的实现思路是:用某个定时器工作在输入捕获模式,捕获LSE引脚上的32768Hz方波;在1秒内统计捕获到的上升沿次数,如果计数接近32768,说明主时钟频率正确;如果计数明显偏多,说明HSI偏快。

另一种更简单的做法是用MCO引脚输出HSI时钟,用频率计直接测出实际频率,然后把这个频率写进Flash里的校准参数区。每次上电时读取,计算ARR补偿值。这个方法不需要额外的参考时钟输入,只需要产线测试时进行一次校准,适合批量生产场景。

我不建议在产品里用太复杂的动态校准算法。因为每次上电重新测量HSI频率本身就耗时,而且测温漂需要持续采样,软件复杂度上去了,收益却不一定值得。对绝大多数场景,固定补偿加温度范围评估就够了。

7. 我的结论与建议

这台C091最终按照客户需求做了固定补偿:因为客户知道自己的工作环境温度相对稳定,板子电源也是固定的LDO输出,实测ARR改成10049之后,连续跑了一整天,累计偏差小于200ms。如果换成HSE,这个数字可以做到1秒以内,但客户不想增加硬件成本,固定补偿已经满足了需求。

从这次调试里我总结出一个经验:用STM32C0做定时相关功能时,不要把HSI默认当成“48.000MHz”。它的真实频率大概率在47.8MHz到48.3MHz之间,这取决于你的板子工作温度和供电电压。设计阶段就要决定是否接受这个误差,而不是等产品做出来了再返工。

另外一个建议是:每个项目的启动日志里,最好把SystemCoreClock、TIM_CLK、PSC、ARR这些关键参数全部打印出来。这个习惯在调试时能帮你省很多时间。尤其当代码经过多个人维护之后,没有人能保证时钟配置没有被改过。

最后分享一个小技巧:如果你怀疑HSI不准,但不想接示波器测定时器引脚,可以在代码里通过MCO把系统时钟引出来,用万用表频率档或者逻辑分析仪直接量MCO引脚上的频率。STM32C0的MCO配置很简单,选HSI作为MCO源,量出来就是HSI的实际频率。这样一个操作就能确认时基问题,比反复改定时器参数要快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询