把STM32Cube HAL的输入捕获玩到脉宽测量这一步,说明前面的频率测量你已经跑通了。老实说,测频率和测脉宽看起来像是一回事——不都是靠输入捕获数边沿吗?真正做起来你会发现,脉宽测量比频率测量麻烦的不是一点点。麻烦的核心在于:测频率你只需要关注一种边沿,测脉宽却得在同一通道上不停地切换上升沿和下降沿,而这个切换动作,在HAL库里有好几个让你措手不及的暗坑。
这篇文章我把从原理到实测的整个过程拆开讲:为什么脉宽测量要切换捕获边沿、切换边沿时HAL库做了什么(以及它没做什么)、代码怎么写才不容易丢数据,最后附上我用信号发生器在不同频率和占空比下的实测记录。适合理清输入捕获基本概念、想直接上手测脉宽的读者,也适合已经被"边沿切换后不再触发中断"这类问题卡住的人。
1. 为什么脉宽测量比频率测量麻烦:先理清思路再动手
1.1 测频率和测脉宽的底层差异
先回顾一下输入捕获最核心的机制:定时器的计数器CNT按内部时钟(比如84MHz)不断递增,当引脚上出现设定的边沿时,CNT当前值会被自动锁存到CCR寄存器,同时触发中断。整个过程不需要CPU参与采样,硬件就完成了"那一刻计数器是多少"的定格。
测频率的思路是:连续捕获两个相同边沿,比如都捕获上升沿,两次CNT之差就是信号的周期。周期算出来,频率自然有了。这个过程中定时器的捕获极性从头到尾都是上升沿,中途不需要改变。
而脉宽测量要的是高电平持续时间:从上升沿到相邻下降沿之间的这一段。上升沿来的时候我记一个CNT值,下降沿来的时候再记一个CNT值,两个值相减,得到的就是高电平宽度对应的计数周期数。问题来了——定时器在某一时刻只能按一种极性捕获,上升沿和下降沿不可能同时触发同一个通道。所以测脉宽必须解决一个核心问题:怎么在捕获到上升沿之后,把通道切换成下降沿捕获?
1.2 单通道连续切换边沿法的完整时序
我的做法是:初始把TIMx_CH1设为上升沿捕获。第一个上升沿到来时,硬件把CNT锁存到CCR,进入捕获中断。中断回调里读出这次上升沿的CNT值,然后立刻把捕获极性从上升沿改成下降沿;接着等到下降沿到来,再一次进中断,读出下降沿的CNT值,再改回上升沿。这样每经过一个"上升沿-下降沿"的轮换,就能完整测出一个高电平脉宽,而且测完自动恢复,继续测下一个。
这套方案在时序上看起来优雅,但对中断响应速度有要求:上升沿和下降沿之间的时间,必须大于从捕获发生到代码完成极性切换的时间。如果被测PWM频率太高、脉宽太窄,CPU还没来得及把极性改成下降沿,下降沿已经过去了,这个脉宽就直接丢了。
1.3 为什么不用双通道方案做脉宽测量
有人可能会问:定时器不是有CH1到CH4好几个通道吗?用CH1捕获上升沿、CH2捕获下降沿,两个通道并行工作不就不需要切换极性了吗?
这个思路理论上成立,但工程实现上有硬伤。要让两个通道分别捕获PWM信号的上升沿和下降沿,前提是同一路信号能同时到达CH1和CH2两个引脚,除非把信号用杜邦线飞线接到两个不同的引脚上,否则做不到。外部飞线会引入额外噪声和延迟,而且两个通道之间的同步完全依赖信号的物理连接,一旦接触不良,测量值会莫名其妙地跳动。所以实际项目中,单通道切换极性仍然是测脉宽最通用的方案。
这里说明一下:STM32的定时器确实有PWM输入模式,把CH1和CH2分别配成直接输入和间接输入,可以用硬件自动完成周期和占空比的测量,不需要CPU切换极性,也不受中断延迟影响。但PWM输入模式测的是"周期+占空比",适合数据分析类场景,而且对引脚映射有特殊要求,我在后面第5节会简单对比。
2. 边沿切换的硬件细节,以及HAL库在这里埋的雷
2.1 CCER寄存器里决定捕获极性的是哪几位
要理解边沿切换,绕不开TIMx_CCER寄存器。以通道1为例,CCER寄存器的CC1P位(bit 1)和CC1NP位(bit 3)共同决定捕获极性:
| CC1NP | CC1P | 捕获极性 |
|---|---|---|
| 0 | 0 | 上升沿 |
| 0 | 1 | 下降沿 |
| 1 | 0 | 上升沿(或下降沿,取决于CC1S) |
| 1 | 1 | 保留 |
在HAL库里,HAL_TIM_IC_Start_IT使能捕获后,默认按照CubeMX里配置的极性工作。如果你想改极性,HAL提供了__HAL_TIM_SET_CAPTURE_POLARITY宏;如果你想读取当前用的是上升沿还是下降沿,可以用__HAL_TIM_GET_CAPTURE_POLARITY宏。这两个宏本质都是在操作CCER寄存器的CCxP位。
2.2 中断回调里改极性的正确打开方式
在HAL库的中断回调中修改捕获极性,代码写出来是这个效果:
if (__HAL_TIM_GET_CAPTURE_POLARITY(&htim2, TIM_CHANNEL_1) == TIM_INPUTCHANNELPOLARITY_RISING) { g_rise_time = HAL_TIM_ReadCapturedValue(&htim2, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTURE_POLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); } else { g_fall_time = HAL_TIM_ReadCapturedValue(&htim2, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTURE_POLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); }HAL库的宏定义中,__HAL_TIM_SET_CAPTURE_POLARITY在改变CC1P位之前会先把CC1E位清零,改完极性之后再写一个新的CCER值。问题就在这一步:CC1E是通道使能位,清零之后,捕获通道实际上已经关闭了。后面的捕获事件不会再产生,中断也不会再进来。
2.3 一个容易被忽略的坑:改完CCxP之后捕获会停
我在帮朋友排查这个问题时,一度以为是自己寄存器记错了。后来翻HAL库源码,看到__HAL_TIM_SET_CAPTURE_POLARITY的实现,才确认这个坑确实存在:宏里只做了"清CC1E"和"改CCxP"两步,并没有把CC1E重新置1。也就是说,用了这个宏之后,捕获功能必然停摆,除非你手动再打开一次。
两条路可以解决:
第一种是改完极性后,立即调用__HAL_TIM_ENABLE_IC(&htim2, TIM_CHANNEL_1)把通道重新使能。
第二种是绕开HAL宏,直接修改寄存器的CCxP位,不清CC1E:
htim2.Instance->CCER ^= TIM_CCER_CC1P; // 翻转捕获极性直接对CC1P位做异或翻转,CC1E保持不动,通道始终处于使能状态,少一次"关-开"操作,时序上也更紧凑。我个人更推荐第二种,尤其是处理高频信号时,这能给CPU省下一点点宝贵的时钟周期——虽然只有几个CPU周期,但丢不丢边沿往往就差这几个周期。
3. 基于TIM2的脉宽测量完整实现
3.1 CubeMX配置:从时钟到通道有哪些关键选项
我这里用STM32F407作为例子,定时器时钟是84MHz。在CubeMX里打开TIM2的配置界面,几项关键设置如下:
- Clock Source选Internal Clock;
- Channel1选Input Capture Direct Mode;
- Prescaler填0,让计数器以84MHz全速跑;
- Counter Period填0xFFFFFFFF(定时器为32位时最大值);
- 初始Capture Polarity选Rising Edge,后面在代码里动态切换;
- 在NVIC Settings里勾选TIM2 global interrupt。
有几个容易看漏的点,我挨个说明。
PSC预分频和Counter Period决定了计数器的"速度"和"量程",脉宽测量追求的是分辨率,所以预分频一开始先给0,让计数周期等于定时器时钟周期。Counter Period在本例中是0xFFFFFFFF,因为TIM2是32位定时器,计数范围从0到0xFFFFFFFF,溢出后回绕到0。如果你的芯片没有32位定时器,只能用16位TIM3、TIM4等,那么Counter Period只能填65535,可测的脉宽上限会被压缩很多,这点到第5节再展开。
输入捕获模式下,CubeMX还提供了一个数字滤波选项ICxF,用来屏蔽信号上的毛刺。实测建议选0011(即N=8,采样频率等于内部时钟),对噪声较大的环境很有帮助。代价是会引入几个计数周期的额外延迟,但对绝大多数脉宽测量场景影响可以忽略。
3.2 中断回调代码与脉宽计算公式
代码结构采用"回调只记录和切换,主循环负责计算和输出"的原则。下面这段是完整的中断回调:
volatile uint32_t g_rise_time = 0; volatile uint32_t g_fall_time = 0; volatile uint32_t g_pulse_ticks = 0; volatile uint8_t g_measure_done = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if ((htim->Instance == TIM2) && (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1)) { uint32_t current_capture = HAL_TIM_ReadCapturedValue(&htim2, TIM_CHANNEL_1); if (__HAL_TIM_GET_CAPTURE_POLARITY(&htim2, TIM_CHANNEL_1) == TIM_INPUTCHANNELPOLARITY_RISING) { g_rise_time = current_capture; htim2.Instance->CCER ^= TIM_CCER_CC1P; // 切到下降沿 } else { g_fall_time = current_capture; if (g_fall_time >= g_rise_time) { g_pulse_ticks = g_fall_time - g_rise_time; } else { g_pulse_ticks = 0xFFFFFFFFu - g_rise_time + g_fall_time + 1u; } g_measure_done = 1; htim2.Instance->CCER ^= TIM_CCER_CC1P; // 切回上升沿 } } }脉宽计算公式里的else分支处理的是计数器回绕的情况。比如上升沿发生在0xFFFFFFF0,下降沿发生在0x00000010,CNT在这期间从0xFFFFFFF0一路上涨、溢出回到0、再继续到0x10,那么实际经过的计数周期数量是0xFFFFFFFF - 0xFFFFFFF0 + 0x00000010 + 1 = 0x20,也就是32个tick。用32位无符号整数直接计算不会出错,因为结果本身就在32位范围内。
在main函数里这样启动捕获:
HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1);Cortex-M4内核定时器中断里,HAL库会自动保存现场、进入HAL_TIM_IRQHandler、调用我的回调函数。注意不要在回调里做串口打印或复杂运算,否则时间就浪费了。
3.3 主循环里的数据处理与打印
主循环只需要检查测量完成标志,再换算成时间:
while (1) { if (g_measure_done) { g_measure_done = 0; float pulse_us = (float)g_pulse_ticks / 84.0f; // 84MHz printf("ticks: %lu, pulse: %.2f us\r\n", g_pulse_ticks, pulse_us); } HAL_Delay(10); }如果你测的是低电平脉宽,思路完全对称:初始极性设为下降沿,下降沿记录起始值,改成上升沿,上升沿到来后做差值。要特别注意把极性切换的方向反过来。
这里还有一个细节值得强调:float转换和printf都不适合放在中断回调里。我见过有人直接在回调里printf,结果串口波特率一高,printf还没发完,下一次捕获已经来了,脉宽数据全乱。回调里只赋值标志,主循环里慢慢算,这是嵌入式里最基本的实时性纪律。
4. 信号发生器实测记录与误差分析
4.1 不同频率与占空比下的实测数据
我在实验室用信号发生器输出方波PWM,接到PA0(TIM2_CH1),逐组测量。信号发生器和STM32板卡用同一路电源供电,先共地。下表是几组有代表性的数据:
| 信号源设置 | 理论脉宽 | 实测ticks | 换算脉宽 | 误差 |
|---|---|---|---|---|
| 10kHz, 50% | 50.00μs | 4200 | 50.00μs | 0 |
| 100kHz, 30% | 3.00μs | 252 | 3.00μs | ±1 tick内 |
| 1kHz, 70% | 700.00μs | 58800 | 700.00μs | 0以内 |
| 10kHz, 12.5% | 12.50μs | 1050 | 12.50μs | ±1 tick内 |
实测下来,只要没发生极性切换延迟问题,读数稳定在理论值附近,误差基本就是±1个计数周期(约11.9ns)的量化误差。10kHz、50%那组重复测了100次,读数全部是4200,没有一跳动,说明这套轮流切换的方案在处理中等频率PWM时非常稳定。
不过要注意:这个"稳定"是有前提的。如果信号频率上到几百kHz甚至1MHz,脉宽仅有1~2μs,CPU在中断回调里执行读值、切换极性的几条指令需要上百ns,虽然不至于立刻出错,但余量开始变小。再往上走,就得考虑第5节说的PWM输入模式或DMA方案了。
4.2 误差从哪来:量化误差、中断延迟与共地问题
第一类是量化误差。CNT本身按84MHz递增,边沿到来瞬间,硬件最快也要等到下一个计数时钟上升沿才能锁存,所以每次捕获都有最多1个计数周期的量化偏差。上升沿和下降沿各偏差一次,理论最大误差是±1个tick,对应约±11.9ns。这个你无法消除,只能通过提高计数器时钟来提高分辨率来降低相对误差。
第二类是中断延迟,也是我踩得最深的一个坑。系统里如果还有其他高优先级中断,或者你在回调里做了耗时操作,从边沿硬件捕获到CPU执行完极性切换之间的时间会变长。如果这段时间超过了被测信号的脉宽,下降沿事件就彻底丢失,测量结果会变得非常大——因为你等的是下一个周期的下降沿。排查这个问题时,最典型的现象就是读数偶尔翻倍或者数值巨大。解决办法是:把TIM2抢占优先级提高,回调里只干活不磨蹭,被测高频信号时尤其如此。
第三类是共地问题。信号发生器和板卡没有共地时,两个地之间存在电位差,测量到的脉宽会随共模电压漂移,读数看起来像"跳动但也看不出规律"。接线时先用万用表确认两边地电阻接近0Ω,再上电测。
5. 溢出、分辨率与可测范围:动手前先算好的三笔账
5.1 16位与32位定时器的脉宽量程对比
在写本篇的完整实现之前,我特意用了TIM2而不是更常见的TIM3、TIM4,就是因为16位定时器在脉宽测量场景下有个明显短板:可测脉宽上限太低。
以16位定时器、84MHz计数、预分频为0为例,满量程只有65536个tick,对应最大脉宽约780μs。换句话说,被测信号的高电平只要超过780μs,计数器就会溢出一次甚至多次,你就必须自己在更新中断里记录溢出次数,再把多段拼起来,逻辑复杂度一下子上升。而32位定时器TIM2/5的计数范围是0到0xFFFFFFFF,84MHz下最大可测脉宽约51秒,常规的PWM、遥控信号、传感器脉冲信号几乎都在这个量程内,完全不需要考虑溢出。
这51秒也不是随便算出来的:2^32 ÷ 84,000,000 ≈ 51.1s。如果换成一个预分频为83的配置,计数器时钟降为1MHz,最大可测脉宽变成约4295秒,但分辨率也从11.9ns变成1μs。量程和分辨率就是这样一对需要反复权衡的指标,下表是几个常用组合的对比,容易看明白:
| 定时器 | 预分频 | 计数器时钟 | 分辨率 | 最大可测脉宽 |
|---|---|---|---|---|
| TIM2/5 (32位) | 0 | 84MHz | 11.9ns | 约51.1s |
| TIM2/5 (32位) | 83 | 1MHz | 1μs | 约4295s |
| TIM3/4 (16位) | 0 | 84MHz | 11.9ns | 约780μs |
| TIM3/4 (16位) | 83 | 1MHz | 1μs | 约65.5ms |
5.2 预分频、分辨率与最大可测脉宽的取舍
看到上面的表你应该已经明白了:选预分频的本质是选择"分辨率优先"还是"量程优先"。我的建议是:
- 测PWM、遥控编码、传感器信号这类通常几百ns到几十ms的事件,直接用32位定时器、预分频0即可。
- 测接近秒级的长时间脉宽,如果坚持高分辨率,那就只能把溢出中断、计数溢出次数做进去;如果对分辨率要求不苛刻,把预分频调大更省事。
- 手头芯片没有32位定时器,又需要测较长脉宽时,必做溢出计数。这个方案其实并不复杂:更新中断里给一个全局变量加1,最终脉宽 = 溢出次数 × (ARR+1) + 末尾捕获值 - 起始捕获值。但我还是想强调,如果换芯片或换定时器能让问题消失,就别在16位上硬犟。
5.3 高出中断处理能力的高频信号怎么办
用单通道切换极性测脉宽,有一个绕不开的物理瓶颈:两个边沿之间的"思考时间"。从理论上讲,上升沿捕获到代码执行完极性切换,中间是CPU的中断响应时间和指令执行时间,STM32F4在168MHz主频下,HAL一套动作下来少说也有几百ns。如果脉宽小于这个时间,下降沿大概率被错过。
遇到这类高频窄脉宽信号,我的经验是分三种情况处理:
第一,如果不需要知道具体脉宽,只需要判断"信号是否有高电平",可以直接用硬件层面的异或或引脚比较功能,但这就不是输入捕获的范畴了。
第二,如果必须用定时器输入捕获,可以尝试PWM输入模式。它利用一个通道的TI1FP1连到CH1、另一个通道的TI2FP2连到CH2,硬件自动锁存周期和占空比,完全不依赖CPU的中断响应速度。缺点是占用了两个通道,且只适用于固定极性的PWM分析。
第三,如果信号本身就是低占空比窄脉冲,比如几微秒的脉冲,实际上上升沿到下降沿的时间很短,但下降沿之后有充足的时间,那单通道方案仍然能用,只要保证中断响应足够快、回调足够精简。我在实测中测过500ns级别的窄脉宽,把定时器时钟配到84MHz、极性切换直接操作寄存器,读数是能稳定复现的;但这里已经没有多少余量了,每一步都省着用才行。
我个人在实际操作中最大的体会是:脉宽测量从来不是"按下F5看串口"那么简单,边沿切换的时机、中断回调里做的事情、定时器的选型这三件事,决定了整个测量方案的上限。如果你刚跑通这段代码,建议先拿信号发生器从10kHz、50%占空比开始测起,确认读数稳定后再逐步提高频率、改窄脉宽。最后再分享一个小技巧:调试时把上升沿和下降沿两次捕获的原始CNT值都打印出来,一旦出现异常,第一件事先看这两个数的差值和方向,很多问题一眼就能定位——这比我当初对着现象瞎猜快多了。