上周帮一个客户调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) |
|---|---|---|---|
| 1 | 995.2 | -4.8 | 48.23 |
| 2 | 995.1 | -4.9 | 48.24 |
| 3 | 995.3 | -4.7 | 48.22 |
| 4 | 995.0 | -5.0 | 48.24 |
| 5 | 995.2 | -4.8 | 48.23 |
| 6 | 995.1 | -4.9 | 48.24 |
| 7 | 995.3 | -4.7 | 48.22 |
| 8 | 995.2 | -4.8 | 48.23 |
| 9 | 995.0 | -5.0 | 48.24 |
| 10 | 995.1 | -4.9 | 48.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的实际频率。这样一个操作就能确认时基问题,比反复改定时器参数要快得多。