☰
STM32 PWM输入捕获从模式复位原理与HAL库实战:测量周期与占空比
2026/10/19 5:26:01 网站建设 项目流程

不少开发板的例程里都能看到"1-23 PWM输入捕获-从模式测量脉冲周期-占空比值"这一节,但很多朋友照着代码抄了一遍,换了个信号源就发现测出来的周期和占空比全是乱的。有人怀疑是硬件坏了,有人怀疑是库函数版本不对,实际上大多数问题都出在没搞懂PWM输入模式这个"从模式复位"到底在硬件层面做了什么。这篇就把原理、配置、实测坑位一次讲透,以STM32F1系列72MHz主频为例,代码用HAL库实现,只要你手里有一块带定时器的板子和一个可调PWM信号源,就能完整复现并验证。

1. PWM输入模式和普通捕获到底差在哪

1.1 普通双通道捕获为什么容易翻车

先说说大家最习惯用的普通输入捕获方案。思路很简单:定时器开两个通道,CH1捕获上升沿,CH2捕获下降沿,然后在中断里记录CNT的值。上升沿记下t0,下降沿记下t1,那么高电平时间就是t1 - t0;再等下一个上升沿记下t2,周期就是t2 - t0。听起来没有任何问题,实际写起来也不复杂,但只要你真正用来测信号,痛点会一个接一个冒出来。

第一个痛点是数据错位。中断是软件响应的,上升沿和下降沿事件到达CPU的时间不确定,一旦某个边沿对应的中断没有及时响应,甚至被更高优先级的中断打断,那t0、t1、t2之间的对应关系就乱了。你算出来的高电平时间可能是上一次周期的,周期可能是上上次的,占空比自然完全失真。而且占空比越接近0%或100%,两个边沿离得越近,中断响应顺序错乱的概率越高。

第二个痛点是CPU负担。普通方案里每个边沿都要进一次中断,1kHz的PWM信号每秒要进2000次中断,100kHz的PWM信号每秒就是20万次中断。中断里还得做减法、记录变量,看起来没多少代码,但高频场景下CPU大部分时间都在响应中断,主循环基本被饿死。

第三个痛点是低频信号测不准。如果你的PWM信号只有几十赫兹,两个边沿间隔几十毫秒,定时器还没等到下降沿就可能溢出了,你还得额外处理溢出中断,把溢出的次数也计入周期,代码复杂度和出错概率一起上升。

"从模式测量脉冲周期-占空比值"这套方案,就是为了同时解决这三个痛点出现的。

1.2 从模式复位带来的"硬件对齐"优势

PWM输入模式的核心,是让硬件而不是软件来完成"周期对齐"这件事。它利用了定时器的从模式功能,把定时器配置成Reset Mode,也就是复位从模式。外部PWM信号的上升沿经过输入滤波和边沿检测后,会直接作为触发源,硬件自动把计数器CNT清零,同时把当前CNT的值锁存到捕获寄存器CCR1里。换句话说,每个PWM周期开始的那一瞬间,CCR1里存的就是"上一个周期结束到这个周期开始"的总计数,也就是周期值。

与此同时,通道2的输入信号在内部被反相处理,PWM信号的下降沿会触发CCR2的捕获,锁存的是从周期开始到下降沿之间的计数值,也就是高电平持续时间。这样CCR1和CCR2天然属于同一个PWM周期,不存在软件错位的问题。

CPU这边只需要在一个边沿中断里把两个寄存器的值读走就行,不需要在中断里做减法,也不需要维护多个状态变量。对比一下普通方案,1kHz信号原来每秒进2000次中断,现在只需要1000次;100kHz信号原来每秒进20万次中断,现在只需要10万次,而且每次中断处理的工作量小得多。

我整理了一个对比表格,方便你决定什么时候用PWM输入模式:

对比项普通双通道捕获PWM输入模式(从模式复位)
数据对齐软件维护,容易错位硬件锁存,天然对齐同一周期
中断次数每个边沿都触发每个周期触发一次主通道捕获
中断内运算需要做减法、存状态直接读取两个捕获值
低频溢出需要额外处理溢出中断仍需处理,但逻辑简单些
配置复杂度直观,但易踩坑需理解从模式,配置项多
适用场景单次测量、低频变化慢连续测量、高频信号、低CPU占用

2. 从模式复位机制拆解:周期和占空比是怎么被同时锁存的

2.1 主模式、从模式到底是什么关系

很多初次接触的人看到"从模式"三个字会困惑:定时器不是自己干活的吗,怎么还有主从关系?这里的主从不是说两个定时器协作,而是指一个定时器内部的两种工作逻辑。

主模式负责产生一个输出信号TRGO,这个信号可以输出给其他外设使用,比如触发ADC采样、触发另一个定时器启动。从模式则是反过来的逻辑:定时器作为一个"从设备",接收外部触发输入信号的控制,根据触发信号执行复位、门控、触发三种动作之一。这两种模式可以同时存在,互不干扰。

在PWM输入捕获这个场景里,我们真正用到的是从模式的Reset复位功能。触发源选择TI1FP1,也就是定时器通道1经过输入滤波和极性选择后的信号。PWM信号的上升沿到达时,硬件立即执行两个动作:把CNT的值写入CCR1,然后把计数器清零。这两个动作是硬件同步完成的,不需要软件介入,所以时间上非常精确。

有人会问,既然计数器的值被锁存到CCR1之后就清零了,那CCR1存的到底是"这一周期的计数值"还是"上一周期的计数值"?严格来说,上升沿到来时CNT还在跑,此时CNT的值是从上一个上升沿到当前这个上升沿之间的计数值,所以这就是一个完整的周期。硬件先把值锁存到CCR1,再把CNT清零,CCR1自然存的就是周期值。

这就是标题里"从模式测量脉冲周期-占空比值"这句话的硬件基础。

2.2 TI1FP1、TI1FP2的信号路径与边沿关系

讲到这里,有必要把定时器输入端的信号路径捋一遍,因为很多人就是在这一步配错的。

定时器通道1的外部引脚信号进来后,会经过三个环节。首先是输入滤波器,用来滤除高频毛刺。然后是边沿检测器,可以配置为上升沿有效、下降沿有效或者双边沿有效。最后经过极性选择,得到真正用来捕获和触发内部信号TI1FP1。

注意,从模式复位用的触发源是TI1FP1,而通道1捕获用的信号也是TI1FP1,两者是同一个信号。这就保证了复位动作和CH1捕获动作严格同步,都在PWM上升沿发生。

通道2这边情况稍特殊。通道2的外部引脚没有接额外的信号,在PWM输入模式下,我们利用的是定时器内部一个巧妙的设计:TI1FP2信号其实是TI1FP1经过反相后的结果。也就是说,通道2捕获到的边沿,正好是PWM信号的反向边沿。PWM上升沿触发TI1FP1,TI1FP2就是下降沿信号;PWM下降沿触发TI1FP1到TI1FP2的变化,CCR2捕获的就是高电平结束的时刻。

这就是为什么在CubeMX配置中,通道1要选Direct模式,通道2要选Indirect模式,通道1的极性是上升沿,通道2的极性是下降沿。所谓Indirect,就是间接映射,IC2捕获的并不是TI2引脚本身,而是内部反相后的TI1信号。

一次完整的PWM周期内,硬件时序是这样的:

  1. PWM上升沿到达,TI1FP1产生触发事件,CNT的值被锁存到CCR1,随后CNT被清零。
  2. PWM信号维持高电平,CNT从0开始向上计数。
  3. PWM下降沿到达,TI1FP2产生捕获事件,当前CNT的值被锁存到CCR2。
  4. PWM信号维持低电平,CNT继续计数,直到下一个上升沿再次触发复位。

等下一个上升沿到来时,CCR1和CCR2里分别保存着周期值和高电平时间值,软件在中断里把两个寄存器读走即可。整个过程硬件自动完成,数据天然属于同一个周期。

2.3 为什么CCR2的值是占空比而不是别的什么

只要理解了上面的时序,就不难得出一个结论:CCR2的值等于从周期开始到下降沿之间的计数值,也就是高电平持续时间。CCR1是周期计数值,占空比就是CCR2除以CCR1。

但这里有个容易混淆的点:CCR2的值不是"低电平时间"。因为计数器是在上升沿复位的,之后先经历高电平再经历低电平,CCR2捕获发生在下降沿那一下,所以它只能代表高电平持续的计数值。如果高电平时间占50%,CCR1是1000,CCR2就是500;如果占空比是75%,CCR2就是750。

占空比接近100%时,下降沿几乎紧挨着下一个上升沿,CCR2会非常接近CCR1;占空比接近0%时,下降沿几乎紧挨着上升沿,CCR2会非常小,甚至可能因为信号本身的抖动导致CCR2为0或者捕获异常。这个问题在后面踩坑部分我会专门展开。

3. 基于HAL库的PWM输入捕获配置

3.1 CubeMX参数设置:通道映射、从模式触发与极性选择

用CubeMX配置PWM输入模式,说起来只有几步,但每一步都有隐藏细节。以TIM2为例,打开定时器配置页面,先看Slave Mode部分,从模式选择Reset Mode,触发源选择TI1FP1。这一步的作用就是让定时器在PWM上升沿时自动复位计数器。

然后是通道配置。Channel1选择Input Capture direct mode,也就是直接捕获TI1的信号。Channel2选择Input Capture indirect mode,注意不是direct。很多人在这一步直接选了direct,导致CH2捕获的是CH2引脚本身的信号,而CH2引脚上根本没接信号,结果CCR2永远读不到有效数据。

极性方面,Channel1配置为Rising Edge,Channel2配置为Falling Edge。这个配置和从模式触发源是配套的,三者缺一不可。如果Channel1的极性配成了下降沿,从模式触发也会跟着变成下降沿复位,那捕获的周期值就变成了低电平时间加上高电平时间混乱的序列,数据完全不可用。

预分频和自动重载值需要一起考虑。以72MHz定时器时钟为例,预分频器设为71,则计数频率为1MHz,counter每1微秒加1。自动重载值设为65535,是16位定时器的上限。如果你用的是TIM2或者TIM5这种32位定时器,可以把自动重载设成更大的值,比如0xFFFFFFFF,这样低频信号也能直接测量。

CubeMX里对应的时钟树也要检查,确认定时器时钟确实是72MHz。很多板子默认APB1总线时钟是36MHz,但定时器时钟会倍频到72MHz,这个倍频逻辑经常被忽略,导致实际计数频率和预期不符,测出来的频率整体偏大。

3.2 GPIO配置的常见误区:输入捕获也是输入

这一部分我要单独拿出来说,因为真的有很多人在这里翻车。输入捕获嘛,很多人潜意识里觉得"捕获"是个主动的动作,就习惯性地把GPIO配置成了复用推挽输出,结果信号根本进不来,CCR1和CCR2永远读不到有效数据,或者只能读到初始值。

实际上,输入捕获的GPIO要配置为输入模式。在CubeMX中,把TIM2_CH1和TIM2_CH2对应的引脚选中后,GPIO模式会默认选择Alternate Function,但输出方式要确认是Input。如果信号源是推挽输出的PWM,GPIO可以配置为浮空输入;如果信号源是开漏输出,则必须配置上拉输入,否则高电平无法被正确识别。

我用过的一个开发板例程里,例程代码把GPIO配置成了GPIO_MODE_AF_PP,也就是复用推挽输出。这个配置在例程自带的信号源场景下居然能工作,原因是该信号源内部还有别的通路,但当你外接一个标准PWM信号时,就测不出来了。这就是典型的"例程能用,但换个场景就废"的情况。

3.3 完整代码实现:初始化、从模式配置与中断回调

CubeMX生成初始化代码后,还需要手动补上从模式配置和捕获通道配置。下面是一份完整的TIM2 PWM输入模式初始化代码,以STM32F103系列72MHz为例。

static TIM_HandleTypeDef s_Tim2Handle; void MX_TIM2_PWMInput_Init(void) { TIM_SlaveConfigTypeDef sSlaveConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; TIM_IC_InitTypeDef sICConfig1 = {0}; TIM_IC_InitTypeDef sICConfig2 = {0}; __HAL_RCC_TIM2_CLK_ENABLE(); s_Tim2Handle.Instance = TIM2; s_Tim2Handle.Init.Prescaler = 71; // 72MHz / (71+1) = 1MHz,每tick 1us s_Tim2Handle.Init.CounterMode = TIM_COUNTERMODE_UP; s_Tim2Handle.Init.Period = 0xFFFFFFFF; // TIM2是32位,低频也能测 s_Tim2Handle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; s_Tim2Handle.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_IC_Init(&s_Tim2Handle); // 从模式:复位,触发源TI1FP1 sSlaveConfig.SlaveMode = TIM_SLAVEMODE_RESET; sSlaveConfig.InputTrigger = TIM_TS_TI1FP1; sSlaveConfig.TriggerPolarity = TIM_INPUTCHANNELPOLARITY_RISING; sSlaveConfig.TriggerFilter = 0; HAL_TIM_SlaveConfigSynchro(&s_Tim2Handle, &sSlaveConfig); // 主模式不需要额外输出,按默认即可 sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&s_Tim2Handle, &sMasterConfig); // CH1 直接捕获上升沿,用于锁存周期 sICConfig1.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING; sICConfig1.ICSelection = TIM_ICSELECTION_DIRECTTI; sICConfig1.ICPrescaler = TIM_ICPSC_DIV1; sICConfig1.ICFilter = 0; HAL_TIM_IC_ConfigChannel(&s_Tim2Handle, &sICConfig1, TIM_CHANNEL_1); // CH2 间接捕获下降沿,用于锁存高电平时间 sICConfig2.ICPolarity = TIM_INPUTCHANNELPOLARITY_FALLING; sICConfig2.ICSelection = TIM_ICSELECTION_INDIRECTTI; sICConfig2.ICPrescaler = TIM_ICPSC_DIV1; sICConfig2.ICFilter = 0; HAL_TIM_IC_ConfigChannel(&s_Tim2Handle, &sICConfig2, TIM_CHANNEL_2); HAL_TIM_IC_Start_IT(&s_Tim2Handle, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(&s_Tim2Handle, TIM_CHANNEL_2); }

这段代码有几个关键点要强调。从模式配置里,TriggerPolarity要配成上升沿,和CH1的捕获极性保持一致。如果你在这里配成了下降沿,从模式会在下降沿复位计数器,根本测不出正确的周期。TriggerFilter是触发输入滤波器,设为0表示不加滤波。如果被测信号比较干净,保持默认即可;如果信号有毛刺,可以适当调大,后面我会专门讲滤波的影响。

两个通道的ICSelection区别很大,CH1是DIRECTTI,直接捕获TI1;CH2是INDIRECTTI,间接捕获TI1FP2,也就是反相后的TI1信号。这个区别是整个PWM输入模式能否正常工作的核心,千万不能搞混。

中断里读取数据的回调函数也很关键:

volatile uint32_t g_uiPeriod = 0; volatile uint32_t g_uiDutyValue = 0; volatile uint8_t g_bDataReady = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { // 必须在CH1中断里同时读取两个寄存器的值 g_uiPeriod = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); g_uiDutyValue = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); g_bDataReady = 1; } } }

注意这里的注释:为什么要在CH1中断里同时读两个值?因为CH1捕获事件发生在PWM上升沿,此时CCR1刚被写入周期值,CCR2里保存的是当前周期的高电平时间,两个值都还处于有效状态。如果等到CH2中断再读,理论上也能读到数据,但CH2中断触发时间在下落沿,此时CCR1存的是上一个周期的值,而CCR2才刚被更新,软件上需要额外判断数据对应关系,很容易出错。直接在CH1中断里一次读走,是最简单的方案。

在主循环里计算频率和占空比:

while (1) { uint32_t uiPeriod; uint32_t uiDutyValue; float fFreq; float fDutyPercent; if (g_bDataReady) { g_bDataReady = 0; uiPeriod = g_uiPeriod; uiDutyValue = g_uiDutyValue; if (uiPeriod > 0) { fFreq = 1000000.0f / uiPeriod; fDutyPercent = (float)uiDutyValue / uiPeriod * 100.0f; } else { fFreq = 0.0f; fDutyPercent = 0.0f; } // 这里输出或显示结果 } }

主循环里使用局部变量复制volatile变量,是为了防止在读取过程中数据被中断更新,导致读到半个周期的值和半个周期的旧值混在一起。虽然对于32位赋值而言大部分架构是原子的,但养成这种习惯能避免后续踩更隐蔽的坑。

4. 数据怎么算、怎么滤波、怎么防丢信号

4.1 频率与占空比的计算公式

在1MHz计数频率下,CCR1的值就是PWM周期的微秒数。比如被测信号是1kHz,CCR1读数就是1000;信号是10kHz,CCR1读数就是100。频率就是1000000除以CCR1,单位是Hz。

占空比的计算更直接:CCR2除以CCR1乘以100。如果CCR2是300,CCR1是1000,占空比就是30%。

不过这里必须提醒一件事:CCR1可能因为信号频率太低而溢出。16位定时器计数到65535就溢出了,对于1MHz计数频率,可测的最大周期是65.535毫秒,对应频率约15.26Hz。低于这个频率,CNT还没等到上升沿就溢出归零了,CCR1捕获到的就不是真实周期。TIM2和TIM5是32位定时器,最大周期可以到4294秒,常规PWM场景完全够用。所以如果你需要测低频信号,优先选用32位定时器。

预分频器的选择也要根据信号范围提前计算。假设被测信号最高频率是100kHz,那么周期最小值是10us。为了至少能够分辨出10us内的上升沿和下降沿,计数频率至少要达到信号频率的数倍以上。1MHz计数频率分辨1us,对100kHz信号有10个计数点的余量,精度尚可。如果信号频率更高,比如1MHz,那就必须把预分频调小甚至设为0,让计数频率达到72MHz,这样每个周期至少有72个计数点,才能保证占空比的精度。

4.2 数据滤波策略:中值滤波和去极值平均

PWM输入模式虽然硬件对齐做得很好,但实测数据不会永远平滑。信号源的抖动、外部干扰、电源噪声,都会让连续几次捕获的CCR1和CCR2存在小范围波动。如果你直接把这些数据用于控制,比如根据占空比调整电机转速,会发现转速有明显抖动。

常见的做法是软件滤波。我最常用的是一级中值滤波加滑动平均,具体思路是:连续采集5组数据,去掉最大值和最小值,对剩下的3组求平均。这个组合既能滤掉偶发的毛刺,又不会让响应变得太迟钝。

#define FILTER_SAMPLE_NUM 5 uint32_t g_auiPeriodBuffer[FILTER_SAMPLE_NUM]; uint32_t g_auiDutyBuffer[FILTER_SAMPLE_NUM]; uint8_t g_ucBufferIndex = 0; void Filter_AddSample(uint32_t uiPeriod, uint32_t uiDutyValue) { g_auiPeriodBuffer[g_ucBufferIndex] = uiPeriod; g_auiDutyBuffer[g_ucBufferIndex] = uiDutyValue; g_ucBufferIndex++; if (g_ucBufferIndex >= FILTER_SAMPLE_NUM) { g_ucBufferIndex = 0; } } uint32_t Filter_GetAverage(uint32_t *pBuffer) { uint32_t uiMin = 0xFFFFFFFF; uint32_t uiMax = 0; uint32_t uiSum = 0; uint8_t i; for (i = 0; i < FILTER_SAMPLE_NUM; i++) { if (pBuffer[i] < uiMin) uiMin = pBuffer[i]; if (pBuffer[i] > uiMax) uiMax = pBuffer[i]; uiSum += pBuffer[i]; } uiSum -= uiMin; uiSum -= uiMax; return uiSum / (FILTER_SAMPLE_NUM - 2); }

这个滤波函数很简单,但有一个前提:信号本身的频率变化不能太快。如果你测的是一个正在快速扫频的信号,比如从1kHz扫到10kHz,中值滤波会把扫频过程中的中间值当成当前值输出,造成滞后。这种场景下应该减少采样点数,或者改用动态窗口。

4.3 信号丢失检测:独立看门狗计时器

PWM输入模式有一个隐患:如果信号突然断开,从模式不再触发复位,CNT继续自由运行,CCR1和CCR2停留在旧值上,程序会一直用最后一次的正确数据输出。这在很多应用场景是不可接受的。

解决办法是利用一个独立的软件定时器或者硬件定时器做超时看门狗。这里不能用当前定时器的CNT来判断,因为从模式复位会不断把CNT清零,CNT的值永远不是真实流逝时间。我通常用SysTick作为基础时钟,在每次CH1捕获中断里顺便把超时时间戳更新一下,然后在主循环里检查当前时间和最后一次捕获时间戳的差值。

volatile uint32_t g_uiLastCaptureTick = 0; void SysTick_Handler(void) { g_uiTickCount++; } void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { g_uiLastCaptureTick = g_uiTickCount; // 读取CCR1和CCR2... } } // 在主循环中检查信号是否丢失 if (g_uiTickCount - g_uiLastCaptureTick > 500) // 500ms没有捕获事件 { // 信号丢失,处理异常逻辑 }

这里的判断条件是"500ms内没有捕获事件"。如果被测信号本身频率很低,比如周期是200ms,那500ms超时时间设置得还不够合理,需要根据最低信号频率动态调整。一个更稳妥的做法是从CCR1当前值估算出信号周期,把超时时间设成当前周期的5到10倍。

5. 实测踩坑记录与排查思路

5.1 踩坑一:GPIO配成复用推挽输出,读数始终为零

这是我见过最多的问题,症状非常明确:程序下载进去后,CCR1和CCR2始终读不到有效数据,偶尔能读到一个固定值,但不管怎么调整被测信号频率,数据都不变。

排查链路是这样的:先用逻辑分析仪或者示波器确认被测信号本身是正常的,排除信号源问题。接着检查GPIO配置,发现MX_GPIO_Init里把TIM2_CH1对应的引脚配置成了GPIO_MODE_AF_PP。在HAL库中,这种模式代表复用推挽输出,引脚是作为输出来工作的。输入捕获的引脚实际上是输入,应该配置成GPIO_MODE_AF_IN或者GPIO_MODE_INPUT。

修复方法就是改GPIO模式。值得多说一句的是,为什么例程里配成AF_PP也能跑?原因在于某些开发板的例程使用了内部回环测试功能,或者信号源驱动能力很强,信号通过引脚内部的保护二极管等方式耦合了进来。这种情况在个别板子上能工作,但极不稳定,换一块板子就完蛋。我建议所有做输入捕获的新手,第一件事就检查GPIO模式。

5.2 踩坑二:CH2极性或映射配错,周期和占空比互换

第二个常见问题更隐蔽,症状是读数也有,频率也对,但占空比不对。有一种典型表现是:占空比显示为"100%减去实际值",比如实际占空比30%,读出来70%。

这种情况基本可以确定,CH2的极性或者映射配置和CH1反了。如果通道2的ICSelection配置成了DIRECTTI,而通道1配置成了INDIRECTTI,那么周期值和高电平时间值就会交换,计算结果彻底错误。还有一种情况是CH2的ICSelection配对了,但ICPolarity配成了上升沿,本来该在下降沿捕获,结果也在上升沿捕获,那么CCR2的值会接近CCR1,占空比读出来接近100%。

排查方法很简单:用一个已知占空比的信号源,设置成50%输出,读一下CCR1和CCR2。如果两者接近相等,说明极性正确;如果CCR2比CCR1大,或者CCR2非常接近CCR1,多半是极性或者映射关系配错了。再设置成25%占空比,如果CCR2明显小于CCR1,且比例关系正确,说明配置恢复正常。

5.3 踩坑三:16位定时器测低频信号,频率显示乱跳

我一开始用TIM3测一个10Hz的PWM信号,结果频率值在15Hz左右乱跳,毫无规律。当时第一反应是滤波没做好,加了重滤波后发现数据更乱了,这才意识到不是噪声问题。

后来查了数据手册,TIM3是16位定时器,最大计数65535。在1MHz计数频率下,10Hz信号的周期是100000us,CNT还没跑到100000就已经溢出了,CNT从零重新开始。上升沿到来时,CCR1捕获的值可能是65000减去某个中间值,也可能是几百,完全取决于CNT溢出了多少次,以及溢出发生后过了多久。这个数据当然没有任何意义。

解决方案要么换用TIM2或者TIM5这种32位定时器,要么在16位定时器上配合更新中断做计数扩展。计数扩展的思路是:在更新中断里把溢出次数记录下来,真实周期等于溢出次数乘以65535再加上CCR1的值。但这样还要额外处理一个问题:从模式复位会被硬件的边沿触发打断,软件需要判断溢出中断发生在哪个周期内,逻辑复杂度上升不少。我的建议是,测低频信号优先选用32位定时器,省心得多。

5.4 踩坑四:占空比接近0%或100%时数值跳变

用PWM输入模式测接近极限的占空比,也会遇到特殊问题。信号占空比接近0%,意味着下降沿几乎紧接着上升沿,CCR2可能只捕获到一个很小的值,甚至在某些时钟相位下捕获到0。由于信号本身存在抖动,每次捕获的CCR2可能时有时无,占空比就在0%和几个百分点之间跳动。

占空比接近100%也有类似问题,下降沿几乎挨着下一个上升沿,此时CCR2接近CCR1,但两个边沿之间的时间太短,如果计数频率不够高,CCR2和CCR1的差值就只有几个tick,占空比在99%和100%之间跳动,绝对误差虽然小,但百分比误差很大。

处理办法要分两个方向。一是从硬件上提高分辨率,把预分频调小,让计数频率尽可能高,把边沿之间的微小时间差用更多tick来量化。二是从软件上增加滞回区域,当CCR2小于某个阈值时直接判定占空比为0%,当CCR2大于CCR1减去某个阈值时直接判定为100%。这样虽然牺牲了一点点精度,但避免了数据跳变带来的控制不稳。

6. 应用扩展:多路信号测量与触发ADC联动

6.1 用一个定时器测一个信号,还是多个定时器并行

PWM输入模式占用了同一个定时器的两个通道,所以一个定时器只能测一路完整信号。如果你的系统里有多路PWM需要同时测量,比如两个电机编码器的PWM输出,或者两路遥控接收信号,就需要用到多个定时器。

STM32F1系列里,TIM1、TIM2、TIM3、TIM4、TIM5都支持PWM输入模式,理论上可以同时测5路信号。每路信号的代码逻辑完全一样,只需要将Handle结构体换成对应的定时器,中断回调函数里根据htim->Instance区分即可。

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { g_uiPeriodTim2 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); g_uiDutyTim2 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); } else if (htim->Instance == TIM3) { g_uiPeriodTim3 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); g_uiDutyTim3 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2); } }

多路并行时要注意中断优先级的分配。如果两路信号频率都很高,中断频繁触发,可能导致其中一个回调持续被另一个打断。常规做法是给快速信号对应的定时器分配更高优先级,或者改用DMA方式读取捕获寄存器进一步降低CPU占用。

6.2 主模式TRGO触发ADC:把PWM测量和采样同步起来

前面提到从模式是核心,但不代表主模式没有用武之地。在电机控制、电源管理等需要"边测PWM边采样的场景"里,主模式TRGO可以把PWM周期和ADC采样精确同步起来。

思路是这样的:定时器配置成PWM输入模式,从模式负责复位计数器和捕获,主模式配置为Update事件触发TRGO。TRGO连接到ADC的触发源,这样每个PWM周期开始时,硬件自动触发一次ADC采样。软件在ADC完成中断里读取采样值,同时读取CCR1和CCR2,就得到了"同一次PWM周期内的占空比信息和模拟量信息"。这种同步是纯硬件完成的,比软件估算相位准确得多。

很多人把这个功能做成"触发器"联动,本质都是一样的:利用PWM输入模式下的定时器更新事件,作为其他外设的同步基准。需要注意的一点是,从模式复位会让CNT频繁清零,定时器更新事件也因此每个周期都会产生一次,所以TRGO的触发频率就等于PWM信号的频率。如果PWM频率过高导致ADC触发太频繁,可以在ADC端配置触发信号的分频或等待机制来限制采样率。

6.3 把PWM输入模式做成一个通用测量组件

当你把单路测量跑通以后,一个很自然的想法是:能不能把这段逻辑抽象成一个通用组件,以后在其他项目里直接调用?

我在实际工程里通常是这样封装的:定义一个结构体,保存定时器句柄、预分频值、最新周期、最新占空比、数据有效标志、信号丢失标志,然后提供三个接口函数,分别是初始化、读取最新数据、配置滤波参数。这样上层业务完全不关心底层是哪个定时器、定时器时钟是多少,只需要拿结果使用即可。

这种封装的另一个好处是方便做单元测试。把一个信号发生器接到不同定时器引脚上,切换不同频率和占空比,只要接口输出的数据准确,就说明组件工作正常。我建议你在自己的项目里也这样尝试,把PWM输入捕获从"一个能跑的例程"升级成"一个能复用的模块"。

最后再分享一个小技巧:如果你手里没有可调PWM信号源,直接用另一个定时器输出PWM,把两个定时器的引脚短接起来,就能实现"自己测自己"的闭环验证。先用这种方式确认配置正确,再去测外部信号,可以少走很多弯路。

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

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

立即咨询